Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

592 lines
21 KiB
Python

"""
Tests for the CancellationMonitor and CancellationHandle classes.
These tests verify that:
1. The centralized monitor correctly detects Redis cancellation signals via MGET
2. Multiple optimizations can be monitored simultaneously
3. Callbacks are invoked when cancellation is detected
4. The context manager works correctly for CancellationHandle
5. The was_cancelled property is thread-safe
6. Redis key cleanup works correctly
"""
import threading
import time
import uuid
from unittest.mock import MagicMock, patch
import pytest
import opik_backend.utils.redis_utils as redis_utils
from opik_backend.studio.cancellation import (
CancellationHandle,
CancellationMonitor,
CANCEL_KEY_PATTERN,
ENV_CANCEL_POLL_INTERVAL_SECS,
)
# Use a short poll interval for faster tests (in seconds, supports float)
TEST_POLL_INTERVAL = "0.1"
class TestCancellationMonitor:
"""Tests for CancellationMonitor singleton."""
@pytest.fixture(autouse=True)
def reset_singletons(self, monkeypatch):
"""Reset singletons and Redis client before each test."""
# Use short poll interval for tests
monkeypatch.setenv(ENV_CANCEL_POLL_INTERVAL_SECS, TEST_POLL_INTERVAL)
# Stop any existing monitor thread
if CancellationMonitor._instance is not None:
try:
CancellationMonitor._instance._stop_event.set()
if CancellationMonitor._instance._thread:
CancellationMonitor._instance._thread.join(timeout=1)
except Exception:
pass
redis_utils._redis_client = None
CancellationMonitor._instance = None
yield
# Cleanup after test
if CancellationMonitor._instance is not None:
try:
CancellationMonitor._instance._stop_event.set()
if CancellationMonitor._instance._thread:
CancellationMonitor._instance._thread.join(timeout=1)
except Exception:
pass
redis_utils._redis_client = None
CancellationMonitor._instance = None
@pytest.fixture
def mock_redis(self):
"""Create a mock Redis client."""
mock_client = MagicMock()
mock_client.mget.return_value = [] # Default: no cancellations
with patch(
"opik_backend.utils.redis_utils._create_redis_client_from_env",
return_value=mock_client
):
yield mock_client
@pytest.fixture
def optimization_id(self):
"""Generate a random optimization ID."""
return str(uuid.uuid4())
def test_monitor_is_singleton(self, mock_redis):
"""Test that CancellationMonitor is a singleton."""
monitor1 = CancellationMonitor()
monitor2 = CancellationMonitor()
assert monitor1 is monitor2
def test_register_starts_monitor_thread(self, mock_redis, optimization_id):
"""Test that registering starts the monitor thread."""
monitor = CancellationMonitor()
callback = MagicMock()
monitor.register(optimization_id, callback)
# Give thread time to start
time.sleep(0.1)
assert monitor._thread is not None
assert monitor._thread.is_alive()
monitor.unregister(optimization_id)
time.sleep(0.2)
def test_unregister_stops_monitor_when_empty(self, mock_redis, optimization_id):
"""Test that unregistering the last optimization stops the monitor thread."""
monitor = CancellationMonitor()
callback = MagicMock()
monitor.register(optimization_id, callback)
time.sleep(0.1)
assert monitor._thread is not None
monitor.unregister(optimization_id)
time.sleep(0.3)
assert monitor._thread is None or not monitor._thread.is_alive()
def test_mget_called_with_all_registered_keys(self, mock_redis):
"""Test that MGET is called with all registered optimization keys."""
monitor = CancellationMonitor()
opt_id1 = str(uuid.uuid4())
opt_id2 = str(uuid.uuid4())
monitor.register(opt_id1, MagicMock())
monitor.register(opt_id2, MagicMock())
# Wait for at least one poll cycle
time.sleep(0.3)
# Check that mget was called
assert mock_redis.mget.called
# Collect all keys from all mget calls
all_keys_checked = set()
for call in mock_redis.mget.call_args_list:
keys = call[0][0]
all_keys_checked.update(keys)
expected_keys = [
CANCEL_KEY_PATTERN.format(opt_id1),
CANCEL_KEY_PATTERN.format(opt_id2),
]
# Both keys should have been checked at some point
for key in expected_keys:
assert key in all_keys_checked, f"Key {key} was never checked. Checked: {all_keys_checked}"
monitor.unregister(opt_id1)
monitor.unregister(opt_id2)
time.sleep(0.2)
def test_callback_invoked_on_cancellation(self, mock_redis, optimization_id):
"""Test that callback is invoked when cancellation is detected."""
# First MGET returns no cancellation, second returns cancellation
mock_redis.mget.side_effect = [
[None], # No cancellation
[b"1"], # Cancelled
]
monitor = CancellationMonitor()
callback_called = threading.Event()
def on_cancelled():
callback_called.set()
monitor.register(optimization_id, on_cancelled)
# Wait for callback
callback_was_called = callback_called.wait(timeout=2)
assert callback_was_called, "Callback should have been invoked"
time.sleep(0.2)
def test_redis_key_deleted_after_cancellation(self, mock_redis, optimization_id):
"""Test that Redis key is deleted after cancellation is detected."""
mock_redis.mget.return_value = [b"1"] # Cancelled
monitor = CancellationMonitor()
callback_called = threading.Event()
monitor.register(optimization_id, lambda: callback_called.set())
callback_called.wait(timeout=2)
time.sleep(0.2)
# Key should have been deleted
expected_key = CANCEL_KEY_PATTERN.format(optimization_id)
mock_redis.delete.assert_called_with(expected_key)
class TestCancellationHandle:
"""Tests for CancellationHandle."""
@pytest.fixture(autouse=True)
def reset_singletons(self, monkeypatch):
"""Reset singletons and Redis client before each test."""
# Use short poll interval for tests
monkeypatch.setenv(ENV_CANCEL_POLL_INTERVAL_SECS, TEST_POLL_INTERVAL)
# Stop any existing monitor thread
if CancellationMonitor._instance is not None:
try:
CancellationMonitor._instance._stop_event.set()
if CancellationMonitor._instance._thread:
CancellationMonitor._instance._thread.join(timeout=1)
except Exception:
pass
redis_utils._redis_client = None
CancellationMonitor._instance = None
yield
# Cleanup after test
if CancellationMonitor._instance is not None:
try:
CancellationMonitor._instance._stop_event.set()
if CancellationMonitor._instance._thread:
CancellationMonitor._instance._thread.join(timeout=1)
except Exception:
pass
redis_utils._redis_client = None
CancellationMonitor._instance = None
@pytest.fixture
def mock_redis(self):
"""Create a mock Redis client."""
mock_client = MagicMock()
mock_client.mget.return_value = []
with patch(
"opik_backend.utils.redis_utils._create_redis_client_from_env",
return_value=mock_client
):
yield mock_client
@pytest.fixture
def optimization_id(self):
"""Generate a random optimization ID."""
return str(uuid.uuid4())
def test_was_cancelled_returns_false_initially(self, mock_redis, optimization_id):
"""Test that was_cancelled returns False before cancellation."""
handle = CancellationHandle(optimization_id)
assert handle.was_cancelled is False
def test_was_cancelled_returns_true_after_cancellation(self, mock_redis, optimization_id):
"""Test that was_cancelled returns True after cancellation is detected."""
mock_redis.mget.return_value = [b"1"] # Cancelled
callback_called = threading.Event()
handle = CancellationHandle(optimization_id, on_cancelled=lambda: callback_called.set())
handle.register()
callback_called.wait(timeout=2)
assert handle.was_cancelled is True
handle.unregister()
time.sleep(0.2)
def test_context_manager_registers_and_unregisters(self, mock_redis, optimization_id):
"""Test that context manager registers on enter and unregisters on exit."""
mock_redis.mget.return_value = [None] # No cancellation
with CancellationHandle(optimization_id) as handle:
assert handle._registered is True
time.sleep(0.1)
assert handle._registered is False
time.sleep(0.2)
def test_context_manager_returns_handle_instance(self, mock_redis, optimization_id):
"""Test that context manager returns the handle instance."""
with CancellationHandle(optimization_id) as handle:
assert isinstance(handle, CancellationHandle)
assert handle.optimization_id == optimization_id
def test_callback_invoked_through_handle(self, mock_redis, optimization_id):
"""Test that callback passed to handle is invoked on cancellation."""
mock_redis.mget.return_value = [b"1"] # Cancelled
callback_called = threading.Event()
with CancellationHandle(optimization_id, on_cancelled=lambda: callback_called.set()) as handle:
callback_was_called = callback_called.wait(timeout=2)
assert callback_was_called, "Callback should have been invoked"
time.sleep(0.2)
def test_multiple_handles_monitored_together(self, mock_redis):
"""Test that multiple handles are monitored by the same monitor thread."""
opt_id1 = str(uuid.uuid4())
opt_id2 = str(uuid.uuid4())
# Return cancellation for second optimization only
def mget_side_effect(keys):
result = []
for key in keys:
if opt_id2 in key:
result.append(b"1") # Cancelled
else:
result.append(None) # Not cancelled
return result
mock_redis.mget.side_effect = mget_side_effect
callback1_called = threading.Event()
callback2_called = threading.Event()
handle1 = CancellationHandle(opt_id1, on_cancelled=lambda: callback1_called.set())
handle2 = CancellationHandle(opt_id2, on_cancelled=lambda: callback2_called.set())
handle1.register()
handle2.register()
# Wait for callback2 (should be called)
callback2_was_called = callback2_called.wait(timeout=2)
# callback1 should NOT be called
callback1_was_called = callback1_called.wait(timeout=0.5)
assert callback2_was_called, "Callback2 should have been invoked"
assert not callback1_was_called, "Callback1 should NOT have been invoked"
handle1.unregister()
handle2.unregister()
time.sleep(0.2)
def test_handle_handles_redis_errors_gracefully(self, mock_redis, optimization_id):
"""Test that handle continues working even if Redis throws an error."""
# First call raises error, subsequent calls return cancellation
error_raised = [False]
def mget_side_effect(keys):
if not error_raised[0]:
error_raised[0] = True
raise Exception("Redis connection error")
return [b"1"] # Cancelled
mock_redis.mget.side_effect = mget_side_effect
callback_called = threading.Event()
with CancellationHandle(optimization_id, on_cancelled=lambda: callback_called.set()) as handle:
callback_was_called = callback_called.wait(timeout=3)
assert callback_was_called, "Callback should have been invoked after Redis error"
time.sleep(0.2)
class TestCancellationStress:
"""Stress tests for concurrent cancellation monitoring."""
@pytest.fixture(autouse=True)
def reset_singletons(self, monkeypatch):
"""Reset singletons and Redis client before each test."""
# Use short poll interval for tests
monkeypatch.setenv(ENV_CANCEL_POLL_INTERVAL_SECS, TEST_POLL_INTERVAL)
# Stop any existing monitor thread
if CancellationMonitor._instance is not None:
try:
CancellationMonitor._instance._stop_event.set()
if CancellationMonitor._instance._thread:
CancellationMonitor._instance._thread.join(timeout=1)
except Exception:
pass
redis_utils._redis_client = None
CancellationMonitor._instance = None
yield
# Cleanup after test
if CancellationMonitor._instance is not None:
try:
CancellationMonitor._instance._stop_event.set()
if CancellationMonitor._instance._thread:
CancellationMonitor._instance._thread.join(timeout=1)
except Exception:
pass
redis_utils._redis_client = None
CancellationMonitor._instance = None
@pytest.fixture
def mock_redis(self):
"""Create a mock Redis client."""
mock_client = MagicMock()
mock_client.mget.return_value = []
with patch(
"opik_backend.utils.redis_utils._create_redis_client_from_env",
return_value=mock_client
):
yield mock_client
def test_many_concurrent_optimizations_with_selective_cancellation(self, mock_redis):
"""
Test many concurrent optimizations where only some are cancelled.
Simulates a realistic scenario:
- 10 optimizations running concurrently
- Only 3 are cancelled (opt 2, 5, 8)
- Verifies only cancelled ones trigger callbacks
- Verifies non-cancelled ones continue running
"""
num_optimizations = 10
cancelled_indices = {2, 5, 8} # Which optimizations to cancel
opt_ids = [str(uuid.uuid4()) for _ in range(num_optimizations)]
callbacks_called = {i: threading.Event() for i in range(num_optimizations)}
handles = []
# Mock MGET to return cancellation signals only for specific optimizations
def mget_side_effect(keys):
results = []
for key in keys:
# Find which optimization this key belongs to
cancelled = False
for i, opt_id in enumerate(opt_ids):
if opt_id in key and i in cancelled_indices:
cancelled = True
break
results.append(b"1" if cancelled else None)
return results
mock_redis.mget.side_effect = mget_side_effect
# Register all optimizations
for i in range(num_optimizations):
handle = CancellationHandle(
opt_ids[i],
on_cancelled=lambda idx=i: callbacks_called[idx].set()
)
handle.register()
handles.append(handle)
# Wait for cancelled ones to be detected
time.sleep(0.5)
# Verify only cancelled optimizations triggered callbacks
for i in range(num_optimizations):
if i in cancelled_indices:
assert callbacks_called[i].is_set(), f"Optimization {i} should have been cancelled"
else:
assert not callbacks_called[i].is_set(), f"Optimization {i} should NOT have been cancelled"
# Cleanup
for handle in handles:
handle.unregister()
time.sleep(0.2)
def test_rapid_registration_and_unregistration(self, mock_redis):
"""
Test rapid registration and unregistration of optimizations.
Simulates optimizations starting and completing quickly.
"""
mock_redis.mget.return_value = [] # No cancellations
num_cycles = 20
for cycle in range(num_cycles):
opt_id = str(uuid.uuid4())
handle = CancellationHandle(opt_id)
handle.register()
# Simulate some work
time.sleep(0.01)
handle.unregister()
# Monitor should have stopped (no active registrations)
time.sleep(0.3)
monitor = CancellationMonitor()
assert monitor._thread is None or not monitor._thread.is_alive()
def test_staggered_cancellations_over_time(self, mock_redis):
"""
Test cancellations that happen at different times.
- 5 optimizations start
- Cancellations arrive staggered (one per poll cycle)
"""
num_optimizations = 5
opt_ids = [str(uuid.uuid4()) for _ in range(num_optimizations)]
callbacks_called = {i: threading.Event() for i in range(num_optimizations)}
cancellation_order = []
# Track which poll cycle we're on
poll_count = [0]
def mget_side_effect(keys):
poll_count[0] += 1
results = []
# Cancel one optimization per poll cycle
cancel_index = poll_count[0] - 1 # 0-indexed
for key in keys:
cancelled = False
for i, opt_id in enumerate(opt_ids):
if opt_id in key and i == cancel_index:
cancelled = True
break
results.append(b"1" if cancelled else None)
return results
mock_redis.mget.side_effect = mget_side_effect
# Register all optimizations
handles = []
for i in range(num_optimizations):
def make_callback(idx):
def callback():
callbacks_called[idx].set()
cancellation_order.append(idx)
return callback
handle = CancellationHandle(opt_ids[i], on_cancelled=make_callback(i))
handle.register()
handles.append(handle)
# Wait for all to be cancelled (should take ~5 poll cycles)
all_cancelled = all(
callbacks_called[i].wait(timeout=3)
for i in range(num_optimizations)
)
assert all_cancelled, "All optimizations should have been cancelled"
# Verify cancellations happened in order (0, 1, 2, 3, 4)
assert cancellation_order == list(range(num_optimizations)), \
f"Cancellations should happen in order, got: {cancellation_order}"
# Cleanup
for handle in handles:
handle.unregister()
time.sleep(0.2)
def test_concurrent_registration_from_multiple_threads(self, mock_redis):
"""
Test thread-safety of registration from multiple threads.
Simulates multiple worker threads registering optimizations simultaneously.
"""
mock_redis.mget.return_value = [] # No cancellations
num_threads = 5
registrations_per_thread = 10
all_handles = []
handles_lock = threading.Lock()
errors = []
def register_optimizations(thread_id):
try:
for i in range(registrations_per_thread):
opt_id = f"thread-{thread_id}-opt-{i}"
handle = CancellationHandle(opt_id)
handle.register()
with handles_lock:
all_handles.append(handle)
time.sleep(0.01) # Small delay to interleave
except Exception as e:
errors.append(e)
# Start multiple threads
threads = []
for t in range(num_threads):
thread = threading.Thread(target=register_optimizations, args=(t,))
threads.append(thread)
thread.start()
# Wait for all threads
for thread in threads:
thread.join(timeout=5)
# No errors should have occurred
assert not errors, f"Errors during concurrent registration: {errors}"
# All handles should be registered
assert len(all_handles) == num_threads * registrations_per_thread
# Cleanup
for handle in all_handles:
handle.unregister()
time.sleep(0.3)