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
554 lines
23 KiB
Python
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")
|