Files
comet-ml--opik/sdks/python/tests/unit/cli/test_migrate_dataset_checkpoint.py
wehub-resource-sync 5a558eb09e
Backend Tests / discover-tests (push) Waiting to run
Backend Tests / ${{ matrix.name }} (push) Blocked by required conditions
Build and Publish SDK / build-and-publish (push) Waiting to run
Build Opik Docker Images / set-version (push) Waiting to run
Build Opik Docker Images / build-backend (push) Blocked by required conditions
Build Opik Docker Images / build-sandbox-executor-python (push) Blocked by required conditions
Build Opik Docker Images / build-python-backend (push) Blocked by required conditions
Build Opik Docker Images / build-frontend (push) Blocked by required conditions
Build Opik Docker Images / create-git-tag (push) Blocked by required conditions
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Waiting to run
Docs - Publish / run (push) Waiting to run
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Waiting to run
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Blocked by required conditions
Frontend Unit Tests / Test on Node 20 (push) Waiting to run
Guardrails E2E Tests / Select Python version matrix (push) Waiting to run
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Blocked by required conditions
Guardrails E2E Tests / 📢 Slack Notification (push) Blocked by required conditions
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Waiting to run
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Blocked by required conditions
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Waiting to run
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Waiting to run
Lint Opik Helm Chart / unittest-helm-chart (push) Waiting to run
Lint Opik Helm Chart / render-equality (push) Waiting to run
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Waiting to run
Python BE E2E Tests / Python BE E2E (push) Waiting to run
Python Backend Tests / run-python-backend-tests (push) Waiting to run
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
SDK E2E Libraries Integration Tests / Check Secrets (push) Waiting to run
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / build-opik (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Blocked by required conditions
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Waiting to run
TypeScript SDK Build & Publish / build-and-publish (push) Waiting to run
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Waiting to run
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

554 lines
23 KiB
Python

"""Tests for ``opik migrate dataset`` checkpoint/resume (OPIK-7168).
Two layers:
* ``TestMigrationCheckpoint`` -- the checkpoint store in isolation
(``opik.cli.migrate.checkpoint``): key derivation, atomic round-trip,
in-flight tracking, corrupt/foreign-schema tolerance, delete lifecycle.
* ``TestCascadeResume`` -- the cascade's resume behaviour
(``cascade_experiments`` with a ``checkpoint``): skip already-completed
experiments, re-migrate an interrupted experiment after deleting its partial
destination data, and seed the progress callback so a resumed run reports the
right completed count instead of starting at 0.
The cascade tests reuse the elaborate REST/client fakes from
``test_migrate_dataset_experiments_cascade`` so the resume behaviour is
exercised against the same call surface the real cascade drives.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, List, Tuple
from unittest.mock import MagicMock
import pytest
from opik.cli.migrate import checkpoint as checkpoint_module
from opik.cli.migrate.checkpoint import (
SCHEMA_VERSION,
MigrationCheckpoint,
checkpoint_key,
checkpoint_path,
load_or_create,
)
from opik.cli.migrate.datasets.experiments import cascade_experiments
from .test_migrate_dataset_experiments_cascade import (
_Experiment,
_ExperimentItem,
_Trace,
_audit,
_cascade_rest_client,
_client_with_recreate_capture,
)
# ---------------------------------------------------------------------------
# Checkpoint store (unit)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_checkpoint_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point the checkpoint store at a tmp dir instead of the real ~/.opik.
The checkpoint now lives at a fixed per-user path (``checkpoint_dir``),
independent of cwd/--audit-log. Redirecting it here keeps the unit tests
hermetic and off the developer's home directory.
"""
cp_dir = tmp_path / "migrate-checkpoints"
cp_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(checkpoint_module, "checkpoint_dir", lambda: cp_dir)
class TestMigrationCheckpoint:
def test_checkpoint_key__stable_and_distinct_per_tuple(self) -> None:
key = checkpoint_key("ws", "proj", "ds")
# Deterministic: same tuple -> same key across calls.
assert key == checkpoint_key("ws", "proj", "ds")
# Distinct tuples -> distinct keys, including the ambiguous shift of a
# separator across the three components (the \x00 join prevents
# "a"+"bc" colliding with "ab"+"c").
assert checkpoint_key("a", "bc", "d") != checkpoint_key("ab", "c", "d")
def test_checkpoint_path__lives_in_checkpoint_dir_keyed_by_hash(self) -> None:
key = checkpoint_key("ws", "proj", "ds")
path = checkpoint_path(key)
assert path.parent == checkpoint_module.checkpoint_dir()
assert path.name == f"opik-migrate-checkpoint-{key}.json"
def test_load_or_create__no_file__starts_fresh(self, tmp_path: Path) -> None:
cp = load_or_create(
workspace="ws",
project="proj",
dataset="ds",
)
assert cp.completed_count == 0
assert cp.in_flight is None
assert not cp.path.exists()
def test_flush_then_load__round_trips_completed_and_in_flight(
self, tmp_path: Path
) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.total_experiments = 3
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-ds"
)
cp.record_dest_trace_ids(["dest-trace-a", "dest-trace-b"])
cp.mark_completed("src-exp-1")
cp.flush()
reloaded = load_or_create(workspace="ws", project="proj", dataset="ds")
assert reloaded.total_experiments == 3
assert reloaded.completed_experiment_ids == {"src-exp-1"}
# ``mark_completed`` clears in_flight, so only the completed set survives.
assert reloaded.in_flight is None
def test_flush_preserves_in_flight_when_not_completed(self, tmp_path: Path) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-ds"
)
cp.record_dest_trace_ids(["dest-trace-a"])
cp.flush()
reloaded = load_or_create(workspace="ws", project="proj", dataset="ds")
assert reloaded.in_flight is not None
assert reloaded.in_flight.source_experiment_id == "src-exp-2"
assert reloaded.in_flight.experiment_name == "exp-2"
assert reloaded.in_flight.dest_dataset_id == "dest-ds"
assert reloaded.in_flight.dest_trace_ids == ["dest-trace-a"]
def test_load_or_create__corrupt_file__starts_fresh(self, tmp_path: Path) -> None:
# A truncated / unparseable checkpoint (e.g. an interrupted write on a
# filesystem without atomic replace) must be treated as "no progress",
# not crash the migration.
key = checkpoint_key("ws", "proj", "ds")
checkpoint_path(key).write_text('{"schema_version": 1, "comp')
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.completed_count == 0
assert cp.in_flight is None
def test_load_or_create__non_bool_dataset_phase_done__starts_fresh(
self, tmp_path: Path
) -> None:
# ``dataset_phase_done`` gates resume-vs-full-plan, so a truthy non-bool
# (the string "false", a non-empty list) must NOT be coerced to True and
# force a resume that skips rename/create/replay. A non-bool is
# malformed -> fresh.
key = checkpoint_key("ws", "proj", "ds")
for bad in ('"false"', "[1]", "1"):
# Use the CURRENT schema so the load reaches the non-bool guard
# rather than short-circuiting on a schema-version mismatch.
checkpoint_path(key).write_text(
f'{{"schema_version": {SCHEMA_VERSION}, "dataset_phase_done": {bad}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.dataset_phase_done is False
def test_load_or_create__malformed_in_flight__starts_fresh(
self, tmp_path: Path
) -> None:
# A checkpoint with the right schema_version but a wrong-shaped
# ``in_flight`` (here a bare string, or a dict missing the required
# ``source_experiment_id``) must fall back to fresh rather than crash
# the CLI with a TypeError/KeyError.
key = checkpoint_key("ws", "proj", "ds")
for bad_in_flight in ('"not-a-dict"', '{"experiment_name": "x"}'):
checkpoint_path(key).write_text(
'{"schema_version": 1, "completed_experiment_ids": ["e1"], '
f'"in_flight": {bad_in_flight}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.completed_count == 0
assert cp.in_flight is None
def test_load_or_create__corrupt_completed_ids__starts_fresh(
self, tmp_path: Path
) -> None:
# ``set("abc")`` / ``set([1, 2])`` don't raise, so a corrupt
# ``completed_experiment_ids`` (a bare string, or a list with non-string
# entries) would silently seed the wrong completed set and make the
# cascade skip/re-run the wrong experiments. It must fall back to fresh.
key = checkpoint_key("ws", "proj", "ds")
for bad in ('"abc"', "[1, 2, 3]", "{}"):
checkpoint_path(key).write_text(
f'{{"schema_version": 1, "completed_experiment_ids": {bad}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.completed_experiment_ids == set()
def test_load_or_create__corrupt_dest_trace_ids__starts_fresh(
self, tmp_path: Path
) -> None:
# Same silent-corruption guard for the in-flight trace-id list.
key = checkpoint_key("ws", "proj", "ds")
checkpoint_path(key).write_text(
'{"schema_version": 1, "in_flight": '
'{"source_experiment_id": "e2", "dest_trace_ids": "trace-1"}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp.in_flight is None
assert cp.completed_count == 0
def test_load_or_create__non_string_resume_names__starts_fresh(
self, tmp_path: Path
) -> None:
# OPIK-7162: source_dataset_id / source_name / temp_dest_name are the
# resume handles that flow unvalidated into client.get_dataset(name=...)
# and stream_dataset_items(dataset_name=...). A hand-edited non-string
# would otherwise reach the API; it must fall back to fresh, matching
# the same corrupt-recovery contract as the id collections. Uses the
# CURRENT schema so the load reaches the field guard (not the schema
# short-circuit).
key = checkpoint_key("ws", "proj", "ds")
for field in ("source_dataset_id", "source_name", "temp_dest_name"):
for bad in ("[1]", "{}", "5"):
checkpoint_path(key).write_text(
f'{{"schema_version": {SCHEMA_VERSION}, '
f'"dataset_phase_done": true, "{field}": {bad}}}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
# Fell back to fresh: no phase-done carried over from the bad file.
assert cp.dataset_phase_done is False
assert getattr(cp, field) is None
def test_load_or_create__unresolvable_home__returns_none(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# A homeless environment (Path.home() raising, as in some CI/containers)
# must NOT crash the migration before it starts -- load_or_create
# returns None and the caller runs without resume support.
def _boom() -> Path:
raise RuntimeError("Could not determine home directory")
monkeypatch.setattr(checkpoint_module, "checkpoint_dir", _boom, raising=True)
assert load_or_create(workspace="ws", project="proj", dataset="ds") is None
def test_flush__write_failure__swallowed_not_raised(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# A checkpoint is only a resume aid: a read-only / full disk on flush
# must be logged and swallowed, never abort an otherwise-healthy
# migration.
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
assert cp is not None
def _readonly(*_a: Any, **_k: Any) -> None:
raise OSError("Read-only file system")
monkeypatch.setattr(Path, "mkdir", _readonly, raising=True)
cp.flush() # must not raise
def test_flush_then_load__round_trips_dest_experiment_id(
self, tmp_path: Path
) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-ds"
)
cp.record_dest_experiment_id("dest-exp-99")
cp.flush()
reloaded = load_or_create(workspace="ws", project="proj", dataset="ds")
assert reloaded.in_flight is not None
assert reloaded.in_flight.dest_experiment_id == "dest-exp-99"
def test_load_or_create__foreign_schema_version__starts_fresh(
self, tmp_path: Path
) -> None:
key = checkpoint_key("ws", "proj", "ds")
checkpoint_path(key).write_text(
'{"schema_version": 999, "completed_experiment_ids": ["src-exp-1"]}'
)
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
# A newer schema we can't interpret is ignored -> fresh start.
assert cp.completed_count == 0
def test_delete__removes_file_and_is_idempotent(self, tmp_path: Path) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.flush()
assert cp.path.exists()
cp.delete()
assert not cp.path.exists()
# Idempotent: deleting again is a no-op, not an error.
cp.delete()
def test_flush__atomic__no_stray_tmp_file_left(self, tmp_path: Path) -> None:
cp = load_or_create(workspace="ws", project="proj", dataset="ds")
cp.flush()
# The temp file used for the atomic write must have been renamed away.
tmp_files = list(tmp_path.glob("*.tmp"))
assert tmp_files == []
# ---------------------------------------------------------------------------
# Cascade resume behaviour
# ---------------------------------------------------------------------------
def _two_experiment_rig() -> Tuple[Any, Any]:
"""Two source experiments, each with one item/trace, wired end-to-end.
Returns ``(rest_client, client)`` ready to pass to ``cascade_experiments``.
"""
exp1 = _Experiment(id="src-exp-1", name="exp-1", dataset_version_id="src-v-1")
exp2 = _Experiment(id="src-exp-2", name="exp-2", dataset_version_id="src-v-1")
item1 = _ExperimentItem(
id="i1",
experiment_id="src-exp-1",
trace_id="src-trace-1",
dataset_item_id="src-ds-item-1",
)
item2 = _ExperimentItem(
id="i2",
experiment_id="src-exp-2",
trace_id="src-trace-2",
dataset_item_id="src-ds-item-1",
)
rest_client = _cascade_rest_client(
experiments_by_dataset={"src-dataset-1": [exp1, exp2]},
items_by_experiment={"exp-1": [item1], "exp-2": [item2]},
traces_by_id={
"src-trace-1": _Trace(id="src-trace-1"),
"src-trace-2": _Trace(id="src-trace-2"),
},
spans_by_trace={"src-trace-1": [], "src-trace-2": []},
)
client = _client_with_recreate_capture(rest_client)
return rest_client, client
def _run_cascade(
rest_client: Any, client: Any, checkpoint: MigrationCheckpoint, **overrides: Any
) -> Tuple[Any, List[Tuple[int, int, str]]]:
"""Run ``cascade_experiments`` with a progress-capturing callback."""
progress_calls: List[Tuple[int, int, str]] = []
kwargs: dict = dict(
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
target_dataset_id="dest-dataset-1",
version_remap={"src-v-1": "dest-v-1"},
item_id_remap={"src-ds-item-1": "dest-ds-item-1"},
audit=_audit(),
checkpoint=checkpoint,
progress_callback=lambda c, t, label: progress_calls.append((c, t, label)),
)
kwargs.update(overrides)
result = cascade_experiments(client, rest_client, **kwargs)
return result, progress_calls
class TestCascadeResume:
def test_skip_completed__does_not_recreate_already_done_experiment(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
# Pretend exp-1 already migrated on a prior run.
cp.mark_completed("src-exp-1")
result, _ = _run_cascade(rest_client, client, cp)
# Only the not-yet-done experiment is recreated.
assert result.experiments_migrated == 1
assert client.create_experiment.call_count == 1
created_names = [
call.kwargs["name"] for call in client.create_experiment.call_args_list
]
assert created_names == ["exp-2"]
def test_skip_completed__all_done__no_recreation(self, tmp_path: Path) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
cp.mark_completed("src-exp-1")
cp.mark_completed("src-exp-2")
result, _ = _run_cascade(rest_client, client, cp)
assert result.experiments_migrated == 0
client.create_experiment.assert_not_called()
def test_happyflow__marks_each_experiment_completed_and_flushes(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
result, _ = _run_cascade(rest_client, client, cp)
assert result.experiments_migrated == 2
assert cp.completed_experiment_ids == {"src-exp-1", "src-exp-2"}
# in_flight is cleared once the last experiment completes.
assert cp.in_flight is None
# Progress was flushed to disk (a re-run would see both done).
reloaded = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
assert reloaded.completed_experiment_ids == {"src-exp-1", "src-exp-2"}
def test_re_migrate_incomplete__deletes_partial_dest_data_before_rerun(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
# exp-1 finished on the prior run; exp-2 was interrupted mid-flight with
# two destination traces and its destination experiment row already
# created (id recorded before creation).
cp.mark_completed("src-exp-1")
cp.mark_in_flight(
"src-exp-2", experiment_name="exp-2", dest_dataset_id="dest-dataset-1"
)
cp.record_dest_trace_ids(["stale-dest-trace-1", "stale-dest-trace-2"])
cp.record_dest_experiment_id("stale-dest-exp")
_run_cascade(rest_client, client, cp)
# Partial traces were deleted (spans cascade on the BE), so they don't
# duplicate on the re-run.
rest_client.traces.delete_traces.assert_called_once()
deleted_trace_ids = rest_client.traces.delete_traces.call_args.kwargs["ids"]
assert set(deleted_trace_ids) == {"stale-dest-trace-1", "stale-dest-trace-2"}
# The exact recorded destination experiment row was deleted BY ID --
# cleanup never does a name lookup, so no same-named peer can be hit.
rest_client.experiments.delete_experiments_by_id.assert_called_once()
deleted_exp_ids = (
rest_client.experiments.delete_experiments_by_id.call_args.kwargs["ids"]
)
assert deleted_exp_ids == ["stale-dest-exp"]
# in_flight is cleared after cleanup so a further crash won't re-delete.
assert cp.in_flight is None
def test_re_migrate_incomplete__no_experiment_row_yet__only_traces_deleted(
self, tmp_path: Path
) -> None:
# Interruption landed after traces were flushed but before the
# experiment row was created: dest_experiment_id is None, so cleanup
# deletes the traces and skips the experiment delete entirely (no
# name lookup that could hit a peer).
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
cp.mark_in_flight(
"src-exp-1", experiment_name="exp-1", dest_dataset_id="dest-dataset-1"
)
cp.record_dest_trace_ids(["stale-dest-trace-1"])
# no record_dest_experiment_id -> dest_experiment_id stays None
_run_cascade(rest_client, client, cp)
rest_client.traces.delete_traces.assert_called_once()
rest_client.experiments.delete_experiments_by_id.assert_not_called()
def test_re_migrate_incomplete__records_dest_ids_before_backend_write(
self, tmp_path: Path
) -> None:
# OPIK-7168 (#533/#536): the destination trace ids and experiment id
# must be flushed to the checkpoint BEFORE the backend write that
# persists them, so a crash in that window still leaves them recorded
# for the next run's cleanup. We assert the checkpoint file on disk
# already carries this experiment's dest trace ids by the time the
# trace-flush happens.
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
flushed_state: dict = {}
original_flush = client.flush
def _capture_on_first_flush() -> None:
# On the first client.flush() (the trace flush), read back the
# checkpoint from disk and snapshot its in-flight trace ids.
if "dest_trace_ids" not in flushed_state:
reloaded = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
flushed_state["dest_trace_ids"] = (
list(reloaded.in_flight.dest_trace_ids)
if reloaded.in_flight
else []
)
return original_flush()
client.flush = MagicMock(side_effect=_capture_on_first_flush)
_run_cascade(rest_client, client, cp)
# By the time the backend trace flush ran, the checkpoint on disk had
# already recorded this experiment's destination trace id.
assert len(flushed_state["dest_trace_ids"]) == 1
def test_progress_seeded__resumed_run_starts_at_completed_count(
self, tmp_path: Path
) -> None:
rest_client, client = _two_experiment_rig()
cp = load_or_create(
workspace="ws",
project="DestProject",
dataset="MyDataset",
)
cp.mark_completed("src-exp-1")
_, progress_calls = _run_cascade(rest_client, client, cp)
# First progress tick reports 1 already-completed of 2 total (not 0),
# so the bar opens at 50% rather than restarting.
assert progress_calls[0] == (1, 2, "exp-2")
# Final tick snaps to total/total "done".
assert progress_calls[-1] == (2, 2, "done")
def test_no_checkpoint__cascade_unchanged(self) -> None:
# Without a checkpoint the cascade behaves exactly as before: both
# experiments migrate, no delete calls, progress counts from 0.
rest_client, client = _two_experiment_rig()
progress_calls: List[Tuple[int, int, str]] = []
result = cascade_experiments(
client,
rest_client,
source_dataset_id="src-dataset-1",
target_dataset_name="MyDataset",
target_project_name="DestProject",
version_remap={"src-v-1": "dest-v-1"},
item_id_remap={"src-ds-item-1": "dest-ds-item-1"},
audit=_audit(),
progress_callback=lambda c, t, label: progress_calls.append((c, t, label)),
)
assert result.experiments_migrated == 2
rest_client.traces.delete_traces.assert_not_called()
assert progress_calls[0] == (0, 2, "exp-1")