5a558eb09e
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
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""Tests for the migration manifest used by opik import for resumable migrations."""
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from opik.cli.migration_manifest import MigrationManifest, MANIFEST_FILENAME
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_base(tmp_path: Path) -> Path:
|
|
return tmp_path
|
|
|
|
|
|
class TestMigrationManifestLifecycle:
|
|
def test_manifest__fresh_instance__status_is_not_started(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
assert manifest.status == "not_started"
|
|
assert not manifest.is_in_progress
|
|
assert not manifest.is_completed
|
|
|
|
def test_start__fresh_manifest__status_becomes_in_progress_and_file_written(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.start()
|
|
assert manifest.is_in_progress
|
|
assert (tmp_base / MANIFEST_FILENAME).exists()
|
|
|
|
def test_complete__after_start__status_becomes_completed(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.start()
|
|
manifest.complete()
|
|
assert manifest.is_completed
|
|
assert not manifest.is_in_progress
|
|
|
|
def test_reset__after_start_with_data__all_state_cleared(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.start()
|
|
trace_file = tmp_base / "projects" / "p1" / "trace_abc.json"
|
|
trace_file.parent.mkdir(parents=True)
|
|
trace_file.touch()
|
|
manifest.mark_file_completed(trace_file)
|
|
manifest.add_trace_mapping("src-1", "dest-1")
|
|
|
|
manifest.reset()
|
|
|
|
assert manifest.status == "not_started"
|
|
assert manifest.completed_count() == 0
|
|
assert manifest.get_trace_id_map() == {}
|
|
|
|
def test_exists__before_any_save__returns_false(self, tmp_base: Path) -> None:
|
|
assert not MigrationManifest.exists(tmp_base)
|
|
|
|
def test_exists__after_start__returns_true(self, tmp_base: Path) -> None:
|
|
MigrationManifest(tmp_base).start()
|
|
assert MigrationManifest.exists(tmp_base)
|
|
|
|
def test_start__called_twice__started_at_preserved(self, tmp_base: Path) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.start()
|
|
|
|
conn = sqlite3.connect(str(tmp_base / MANIFEST_FILENAME))
|
|
first_started_at = conn.execute(
|
|
"SELECT value FROM status WHERE key = 'started_at'"
|
|
).fetchone()[0]
|
|
conn.close()
|
|
|
|
manifest.start() # second call must not overwrite started_at
|
|
|
|
conn = sqlite3.connect(str(tmp_base / MANIFEST_FILENAME))
|
|
second_started_at = conn.execute(
|
|
"SELECT value FROM status WHERE key = 'started_at'"
|
|
).fetchone()[0]
|
|
conn.close()
|
|
|
|
assert second_started_at == first_started_at
|
|
|
|
|
|
class TestFileTracking:
|
|
def _make_file(self, base: Path, relative: str) -> Path:
|
|
p = base / relative
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.touch()
|
|
return p
|
|
|
|
def test_is_file_completed__fresh_manifest__returns_false(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
f = self._make_file(tmp_base, "datasets/dataset_foo.json")
|
|
assert not manifest.is_file_completed(f)
|
|
|
|
def test_mark_file_completed__new_file__recorded_and_count_incremented(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
f = self._make_file(tmp_base, "datasets/dataset_foo.json")
|
|
manifest.mark_file_completed(f)
|
|
assert manifest.is_file_completed(f)
|
|
assert manifest.completed_count() == 1
|
|
|
|
def test_mark_file_completed__same_file_twice__count_remains_one(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
f = self._make_file(tmp_base, "datasets/dataset_foo.json")
|
|
manifest.mark_file_completed(f)
|
|
manifest.mark_file_completed(f)
|
|
assert manifest.completed_count() == 1
|
|
|
|
def test_mark_file_failed__new_file__recorded_with_error(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
f = self._make_file(tmp_base, "projects/p1/trace_abc.json")
|
|
manifest.mark_file_failed(f, "timeout")
|
|
assert manifest.failed_count() == 1
|
|
assert manifest.get_failed_files()[manifest.relative_path(f)] == "timeout"
|
|
|
|
def test_mark_file_completed__previously_failed_file__removed_from_failed(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
f = self._make_file(tmp_base, "projects/p1/trace_abc.json")
|
|
manifest.mark_file_failed(f, "timeout")
|
|
manifest.mark_file_completed(f)
|
|
assert manifest.failed_count() == 0
|
|
assert manifest.is_file_completed(f)
|
|
|
|
def test_relative_path__nested_file__returns_path_relative_to_base(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
f = self._make_file(tmp_base, "projects/my-project/trace_xyz.json")
|
|
assert manifest.relative_path(f) == str(
|
|
Path("projects") / "my-project" / "trace_xyz.json"
|
|
)
|
|
|
|
|
|
class TestTraceIdMapping:
|
|
def test_add_trace_mapping__single_entry__retrievable_via_get_map(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.add_trace_mapping("src-111", "dest-222")
|
|
manifest.save()
|
|
assert manifest.get_trace_id_map() == {"src-111": "dest-222"}
|
|
|
|
def test_trace_id_map__save_then_new_instance__mapping_survives(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
m1 = MigrationManifest(tmp_base)
|
|
m1.start()
|
|
m1.add_trace_mapping("src-aaa", "dest-bbb")
|
|
m1.save()
|
|
|
|
m2 = MigrationManifest(tmp_base)
|
|
assert m2.get_trace_id_map() == {"src-aaa": "dest-bbb"}
|
|
|
|
def test_get_trace_id_map__mutate_returned_dict__original_unaffected(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.add_trace_mapping("src-1", "dest-1")
|
|
manifest.save()
|
|
copy = manifest.get_trace_id_map()
|
|
copy["src-2"] = "dest-2" # mutate the copy
|
|
assert "src-2" not in manifest.get_trace_id_map()
|
|
|
|
def test_add_trace_mapping__same_src_id_twice__last_dest_wins(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.add_trace_mapping("src-1", "dest-old")
|
|
manifest.add_trace_mapping("src-1", "dest-new")
|
|
manifest.save()
|
|
assert manifest.get_trace_id_map()["src-1"] == "dest-new"
|
|
|
|
|
|
class TestDatabaseIntegrity:
|
|
def test_manifest_file__after_start_and_save__is_valid_sqlite_with_expected_tables(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.start()
|
|
manifest.add_trace_mapping("a", "b")
|
|
manifest.save()
|
|
|
|
conn = sqlite3.connect(str(tmp_base / MANIFEST_FILENAME))
|
|
tables = {
|
|
row[0]
|
|
for row in conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
).fetchall()
|
|
}
|
|
conn.close()
|
|
|
|
assert {"status", "completed_files", "failed_files", "trace_id_map"} <= tables
|
|
|
|
def test_save__after_start__no_tmp_file_created(self, tmp_base: Path) -> None:
|
|
manifest = MigrationManifest(tmp_base)
|
|
manifest.start()
|
|
assert not (tmp_base / "migration_manifest.tmp").exists()
|
|
|
|
def test_mark_file_completed__duplicate_write__count_remains_one(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
"""INSERT OR IGNORE means re-flushing the same path never duplicates rows."""
|
|
manifest = MigrationManifest(tmp_base, batch_size=1)
|
|
f = tmp_base / "projects" / "p" / "trace_x.json"
|
|
f.parent.mkdir(parents=True)
|
|
f.touch()
|
|
manifest.mark_file_completed(f)
|
|
manifest.mark_file_completed(f)
|
|
assert manifest.completed_count() == 1
|
|
|
|
|
|
class TestBatchingBehavior:
|
|
"""Document and verify the crash trade-off introduced by batched writes."""
|
|
|
|
def _make_trace(self, base: Path, name: str) -> Path:
|
|
p = base / "projects" / "proj" / name
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.touch()
|
|
return p
|
|
|
|
def test_mark_file_completed__within_batch__visible_via_api_before_flush(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
"""Buffered completions are visible through the public API (which flushes
|
|
before querying) even before the batch threshold is reached."""
|
|
manifest = MigrationManifest(tmp_base, batch_size=50)
|
|
f = self._make_trace(tmp_base, "trace_001.json")
|
|
manifest.mark_file_completed(f)
|
|
# completed_count() flushes first, so the buffered write IS visible.
|
|
assert manifest.completed_count() == 1
|
|
assert manifest.is_file_completed(f)
|
|
|
|
def test_mark_file_completed__crash_before_flush__unflushed_data_lost(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
"""Simulates a crash (new instance, no save()) with batch_size > pending count.
|
|
|
|
This is the documented trade-off: up to batch_size-1 completions may be
|
|
absent from the manifest after a crash. Those files will simply be
|
|
re-imported on resume.
|
|
"""
|
|
f = self._make_trace(tmp_base, "trace_001.json")
|
|
|
|
m1 = MigrationManifest(tmp_base, batch_size=50)
|
|
m1.start() # always flushes immediately
|
|
m1.mark_file_completed(f) # buffered — NOT yet on disk
|
|
# Simulate crash: m1 is abandoned without save() or complete().
|
|
# __del__ is NOT called here because m1 is still in scope when m2 is created.
|
|
|
|
m2 = MigrationManifest(tmp_base, batch_size=50)
|
|
# The completion is not on disk — resume will re-process this file.
|
|
assert not m2.is_file_completed(f)
|
|
assert m2.completed_count() == 0
|
|
|
|
def test_mark_file_completed__batch_size_one__each_write_immediately_durable(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
"""With batch_size=1 every completion is immediately committed to disk."""
|
|
f = self._make_trace(tmp_base, "trace_001.json")
|
|
|
|
m1 = MigrationManifest(tmp_base, batch_size=1)
|
|
m1.start()
|
|
m1.mark_file_completed(f) # auto-flushes (batch_size=1)
|
|
|
|
# New instance reads from disk — must see the completion.
|
|
m2 = MigrationManifest(tmp_base, batch_size=1)
|
|
assert m2.is_file_completed(f)
|
|
assert m2.completed_count() == 1
|
|
|
|
def test_mark_file_completed__batch_threshold_reached__auto_flushed_to_disk(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
"""When pending count hits batch_size the buffer is auto-flushed."""
|
|
batch_size = 3
|
|
files = [self._make_trace(tmp_base, f"trace_{i:03d}.json") for i in range(3)]
|
|
|
|
m1 = MigrationManifest(tmp_base, batch_size=batch_size)
|
|
m1.start()
|
|
for f in files:
|
|
m1.mark_file_completed(f) # third call triggers auto-flush
|
|
|
|
# New instance must see all three completions.
|
|
m2 = MigrationManifest(tmp_base, batch_size=batch_size)
|
|
assert m2.completed_count() == 3
|
|
for f in files:
|
|
assert m2.is_file_completed(f)
|
|
|
|
|
|
class TestResumeScenario:
|
|
"""Simulate a real interrupted + resumed migration."""
|
|
|
|
def test_resume__interrupted_import__completed_files_skipped_and_id_map_available(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
"""After an interrupted run the second run skips already-flushed files.
|
|
|
|
batch_size=1 so every mark_file_completed is immediately durable —
|
|
this models the boundary between two process invocations where only
|
|
flushed data survives.
|
|
"""
|
|
trace_files = []
|
|
for i in range(3):
|
|
p = tmp_base / "projects" / "proj" / f"trace_{i:03d}.json"
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.touch()
|
|
trace_files.append(p)
|
|
|
|
m1 = MigrationManifest(tmp_base, batch_size=1)
|
|
m1.start()
|
|
m1.add_trace_mapping("src-0", "dest-0")
|
|
m1.mark_file_completed(trace_files[0])
|
|
m1.add_trace_mapping("src-1", "dest-1")
|
|
m1.mark_file_completed(trace_files[1])
|
|
# Process crashes before trace_files[2] — no complete() call.
|
|
|
|
m2 = MigrationManifest(tmp_base)
|
|
assert m2.is_in_progress
|
|
assert m2.completed_count() == 2
|
|
assert m2.is_file_completed(trace_files[0])
|
|
assert m2.is_file_completed(trace_files[1])
|
|
assert not m2.is_file_completed(trace_files[2])
|
|
|
|
id_map = m2.get_trace_id_map()
|
|
assert id_map["src-0"] == "dest-0"
|
|
assert id_map["src-1"] == "dest-1"
|
|
assert "src-2" not in id_map
|
|
|
|
def test_reset__after_completed_files__all_state_cleared(
|
|
self, tmp_base: Path
|
|
) -> None:
|
|
trace_file = tmp_base / "projects" / "p" / "trace_abc.json"
|
|
trace_file.parent.mkdir(parents=True)
|
|
trace_file.touch()
|
|
|
|
m1 = MigrationManifest(tmp_base)
|
|
m1.start()
|
|
m1.mark_file_completed(trace_file)
|
|
|
|
m2 = MigrationManifest(tmp_base)
|
|
m2.reset()
|
|
|
|
assert m2.completed_count() == 0
|
|
assert not m2.is_file_completed(trace_file)
|
|
assert m2.status == "not_started"
|