Files
wehub-resource-sync 498b235461
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:17 +08:00

1207 lines
52 KiB
Python

"""
Base class for CDC sync tests with common utilities.
"""
import logging
import random
import string
import time
from collections.abc import Callable
from datetime import datetime
from typing import Any
from pymilvus import DataType, MilvusClient
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - [%(funcName)s:%(lineno)d] - %(message)s",
)
logger = logging.getLogger(__name__)
class TestCDCSyncBase:
"""Base class for CDC sync tests with common utilities."""
SEARCH_OVERLAP_THRESHOLD = 0.8
FTS_SENTENCES = [
"Vector databases enable efficient similarity search at scale.",
"Machine learning models produce high-dimensional embeddings for semantic search.",
"BM25 is a classic full-text search ranking function used in information retrieval.",
"Approximate nearest neighbor algorithms trade accuracy for speed.",
"Milvus supports hybrid search combining dense and sparse vectors.",
"Transformer models like BERT generate contextual word embeddings.",
"Inverted indexes power traditional keyword-based search engines.",
"HNSW graph structure allows fast approximate nearest neighbor queries.",
"Data replication ensures high availability in distributed databases.",
"CDC stands for Change Data Capture, tracking database mutations in real time.",
"Sparse vectors represent text documents in a high-dimensional term space.",
"Index partitioning improves query throughput in large vector collections.",
"Cosine similarity measures the angle between two vectors in embedding space.",
"Product quantization compresses vectors to reduce memory footprint.",
"Segment-level operations allow incremental updates without full re-indexing.",
"Full-text search ranks documents by term frequency and inverse document frequency.",
"Recall measures the fraction of true neighbors found by an approximate search.",
"Distributed query execution parallelizes workloads across multiple nodes.",
"Schema evolution allows adding new fields without dropping existing collections.",
"Hybrid ranking merges keyword and semantic scores for improved relevance.",
]
@staticmethod
def gen_unique_name(prefix: str = "", length: int = 8, max_length: int = None) -> str:
"""Generate a unique string with prefix and timestamp."""
chars = string.ascii_letters + string.digits
random_str = "".join(random.choice(chars) for _ in range(length))
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] # milliseconds
name = f"{prefix}_{timestamp}_{random_str}"
# If max_length is specified and name exceeds it, truncate intelligently
if max_length and len(name) > max_length:
# Keep the random suffix for uniqueness, truncate prefix and timestamp
suffix_len = length + 1 # +1 for underscore
available_len = max_length - suffix_len
if available_len > 0:
# Use shorter timestamp format for space
short_timestamp = datetime.now().strftime("%m%d_%H%M%S") # 11 chars
truncated_prefix = prefix[: available_len - len(short_timestamp) - 1] if len(prefix) > 0 else ""
name = (
f"{truncated_prefix}_{short_timestamp}_{random_str}"
if truncated_prefix
else f"{short_timestamp}_{random_str}"
)
else:
# Fallback: just use random string
name = random_str
return name
@staticmethod
def wait_for_sync(
check_func: Callable[[], bool],
timeout: int = 120,
operation_name: str = "operation",
) -> bool:
"""
Wait for sync operation to complete with progress logging.
Args:
check_func: Function that returns True when sync is complete
timeout: Timeout in seconds
operation_name: Name of operation for logging
Returns:
True if sync completed, False if timeout
"""
start_time = time.time()
check_interval = 2
last_log_time = start_time
logger.info(f"Starting sync wait for: {operation_name}")
while time.time() - start_time < timeout:
try:
if check_func():
elapsed = time.time() - start_time
logger.info(f"[SUCCESS] {operation_name} synced successfully in {elapsed:.2f}s")
return True
except Exception as e:
elapsed = time.time() - start_time
logger.warning(f"Sync check failed for {operation_name} at {elapsed:.1f}s: {e}")
elapsed = time.time() - start_time
# Log every 10 seconds or on first check
if elapsed - (last_log_time - start_time) >= 10 or elapsed <= check_interval:
progress = (elapsed / timeout) * 100
logger.info(f"[WAITING] {operation_name} sync... {elapsed:.1f}s elapsed ({progress:.1f}% of timeout)")
last_log_time = time.time()
time.sleep(check_interval)
elapsed = time.time() - start_time
logger.error(f"[FAILED] {operation_name} sync failed after {elapsed:.2f}s timeout")
return False
@staticmethod
def create_default_schema(client):
"""Create default collection schema for testing using MilvusClient API."""
# Create schema using MilvusClient API like in the example
schema = client.create_schema(enable_dynamic_field=True)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=128)
return schema
@staticmethod
def create_comprehensive_schema(client):
"""Create schema with comprehensive data types for testing (max 4 vector fields)."""
schema = client.create_schema(enable_dynamic_field=True)
# Primary key
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
# Vector fields (limited to 4 total)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("float16_vector", DataType.FLOAT16_VECTOR, dim=64)
schema.add_field("binary_vector", DataType.BINARY_VECTOR, dim=128)
schema.add_field("sparse_vector", DataType.SPARSE_FLOAT_VECTOR)
# Scalar fields
schema.add_field("bool_field", DataType.BOOL)
schema.add_field("int8_field", DataType.INT8)
schema.add_field("int16_field", DataType.INT16)
schema.add_field("int32_field", DataType.INT32)
schema.add_field("int64_field", DataType.INT64)
schema.add_field("float_field", DataType.FLOAT)
schema.add_field("double_field", DataType.DOUBLE)
schema.add_field("varchar_field", DataType.VARCHAR, max_length=1000)
# Array fields
schema.add_field("bool_array", DataType.ARRAY, element_type=DataType.BOOL, max_capacity=100)
schema.add_field("int32_array", DataType.ARRAY, element_type=DataType.INT32, max_capacity=100)
schema.add_field("int64_array", DataType.ARRAY, element_type=DataType.INT64, max_capacity=100)
schema.add_field("float_array", DataType.ARRAY, element_type=DataType.FLOAT, max_capacity=100)
schema.add_field(
"double_array",
DataType.ARRAY,
element_type=DataType.DOUBLE,
max_capacity=100,
)
schema.add_field(
"varchar_array",
DataType.ARRAY,
element_type=DataType.VARCHAR,
max_capacity=100,
max_length=100,
)
# JSON field
schema.add_field("json_field", DataType.JSON)
return schema
@staticmethod
def create_comprehensive_manual_id_schema(client):
"""Create comprehensive schema with manual ID for upsert operations (max 4 vector fields)."""
schema = client.create_schema(enable_dynamic_field=True)
# Primary key - manual ID
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=False)
# Vector fields (limited to 4 total)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("float16_vector", DataType.FLOAT16_VECTOR, dim=64)
schema.add_field("binary_vector", DataType.BINARY_VECTOR, dim=128)
schema.add_field("sparse_vector", DataType.SPARSE_FLOAT_VECTOR)
# Scalar fields
schema.add_field("bool_field", DataType.BOOL)
schema.add_field("int8_field", DataType.INT8)
schema.add_field("int16_field", DataType.INT16)
schema.add_field("int32_field", DataType.INT32)
schema.add_field("int64_field", DataType.INT64)
schema.add_field("float_field", DataType.FLOAT)
schema.add_field("double_field", DataType.DOUBLE)
schema.add_field("varchar_field", DataType.VARCHAR, max_length=1000)
# Array fields
schema.add_field("bool_array", DataType.ARRAY, element_type=DataType.BOOL, max_capacity=100)
schema.add_field("int32_array", DataType.ARRAY, element_type=DataType.INT32, max_capacity=100)
schema.add_field("int64_array", DataType.ARRAY, element_type=DataType.INT64, max_capacity=100)
schema.add_field("float_array", DataType.ARRAY, element_type=DataType.FLOAT, max_capacity=100)
schema.add_field(
"double_array",
DataType.ARRAY,
element_type=DataType.DOUBLE,
max_capacity=100,
)
schema.add_field(
"varchar_array",
DataType.ARRAY,
element_type=DataType.VARCHAR,
max_capacity=100,
max_length=100,
)
# JSON field
schema.add_field("json_field", DataType.JSON)
return schema
@staticmethod
def create_comprehensive_schema_alt(client):
"""Create alternative comprehensive schema with BFLOAT16_VECTOR and INT8_VECTOR (max 4 vector fields)."""
schema = client.create_schema(enable_dynamic_field=True)
# Primary key
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
# Vector fields (alternative set - limited to 4 total)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("bfloat16_vector", DataType.BFLOAT16_VECTOR, dim=64)
schema.add_field("int8_vector", DataType.INT8_VECTOR, dim=128)
schema.add_field("sparse_vector", DataType.SPARSE_FLOAT_VECTOR)
# Scalar fields
schema.add_field("bool_field", DataType.BOOL)
schema.add_field("int8_field", DataType.INT8)
schema.add_field("int16_field", DataType.INT16)
schema.add_field("int32_field", DataType.INT32)
schema.add_field("int64_field", DataType.INT64)
schema.add_field("float_field", DataType.FLOAT)
schema.add_field("double_field", DataType.DOUBLE)
schema.add_field("varchar_field", DataType.VARCHAR, max_length=1000)
# Array fields
schema.add_field("bool_array", DataType.ARRAY, element_type=DataType.BOOL, max_capacity=100)
schema.add_field("int32_array", DataType.ARRAY, element_type=DataType.INT32, max_capacity=100)
schema.add_field("int64_array", DataType.ARRAY, element_type=DataType.INT64, max_capacity=100)
schema.add_field("float_array", DataType.ARRAY, element_type=DataType.FLOAT, max_capacity=100)
schema.add_field(
"double_array",
DataType.ARRAY,
element_type=DataType.DOUBLE,
max_capacity=100,
)
schema.add_field(
"varchar_array",
DataType.ARRAY,
element_type=DataType.VARCHAR,
max_capacity=100,
max_length=100,
)
# JSON field
schema.add_field("json_field", DataType.JSON)
return schema
@staticmethod
def create_comprehensive_manual_id_schema_alt(client):
"""Create alternative comprehensive schema with manual ID, BFLOAT16_VECTOR and INT8_VECTOR (max 4 vector fields)."""
schema = client.create_schema(enable_dynamic_field=True)
# Primary key - manual ID
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=False)
# Vector fields (alternative set - limited to 4 total)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("bfloat16_vector", DataType.BFLOAT16_VECTOR, dim=64)
schema.add_field("int8_vector", DataType.INT8_VECTOR, dim=128)
schema.add_field("sparse_vector", DataType.SPARSE_FLOAT_VECTOR)
# Scalar fields
schema.add_field("bool_field", DataType.BOOL)
schema.add_field("int8_field", DataType.INT8)
schema.add_field("int16_field", DataType.INT16)
schema.add_field("int32_field", DataType.INT32)
schema.add_field("int64_field", DataType.INT64)
schema.add_field("float_field", DataType.FLOAT)
schema.add_field("double_field", DataType.DOUBLE)
schema.add_field("varchar_field", DataType.VARCHAR, max_length=1000)
# Array fields
schema.add_field("bool_array", DataType.ARRAY, element_type=DataType.BOOL, max_capacity=100)
schema.add_field("int32_array", DataType.ARRAY, element_type=DataType.INT32, max_capacity=100)
schema.add_field("int64_array", DataType.ARRAY, element_type=DataType.INT64, max_capacity=100)
schema.add_field("float_array", DataType.ARRAY, element_type=DataType.FLOAT, max_capacity=100)
schema.add_field(
"double_array",
DataType.ARRAY,
element_type=DataType.DOUBLE,
max_capacity=100,
)
schema.add_field(
"varchar_array",
DataType.ARRAY,
element_type=DataType.VARCHAR,
max_capacity=100,
max_length=100,
)
# JSON field
schema.add_field("json_field", DataType.JSON)
return schema
@staticmethod
def create_manual_id_schema(client):
"""Create collection schema with manual ID for upsert operations."""
# Create schema using MilvusClient API with manual ID
schema = client.create_schema(enable_dynamic_field=True)
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=False)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=128)
return schema
@staticmethod
def generate_test_data(count: int = 100) -> list[dict[str, Any]]:
"""Generate test data for insert operations."""
return [
{
"id": i,
"vector": [random.random() for _ in range(128)],
"text": f"test_text_{i}",
"number": i,
"metadata": {"type": "test", "value": i},
}
for i in range(count)
]
@staticmethod
def generate_test_data_with_id(count: int = 100, start_id: int = 0) -> list[dict[str, Any]]:
"""Generate test data with manual IDs for upsert operations."""
return [
{
"id": start_id + i,
"vector": [random.random() for _ in range(128)],
"text": f"test_text_{i}",
"number": i,
"metadata": {"type": "test", "value": i},
}
for i in range(count)
]
@staticmethod
def generate_comprehensive_test_data(count: int = 100) -> list[dict[str, Any]]:
"""Generate comprehensive test data with all data types using standard vector generation."""
# Generate vectors using standard method
float_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.FLOAT_VECTOR)
float16_vectors = TestCDCSyncBase._gen_vectors(count, 64, DataType.FLOAT16_VECTOR)
binary_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.BINARY_VECTOR)
sparse_vectors = TestCDCSyncBase._gen_vectors(count, 1000, DataType.SPARSE_FLOAT_VECTOR)
data = []
for i in range(count):
record = {
# Vector fields (limited to 4 total)
"float_vector": float_vectors[i],
"float16_vector": float16_vectors[i],
"binary_vector": binary_vectors[i],
"sparse_vector": sparse_vectors[i],
# Scalar fields
"bool_field": random.choice([True, False]),
"int8_field": random.randint(-128, 127),
"int16_field": random.randint(-32768, 32767),
"int32_field": random.randint(-2147483648, 2147483647),
"int64_field": random.randint(-9223372036854775808, 9223372036854775807),
"float_field": random.uniform(-1000.0, 1000.0),
"double_field": random.uniform(-1000.0, 1000.0),
"varchar_field": f"test_varchar_{i}_{random.randint(1000, 9999)}",
# Array fields
"bool_array": [random.choice([True, False]) for _ in range(random.randint(1, 10))],
"int32_array": [random.randint(-100, 100) for _ in range(random.randint(1, 10))],
"int64_array": [random.randint(-1000, 1000) for _ in range(random.randint(1, 10))],
"float_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"double_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"varchar_array": [f"array_str_{j}" for j in range(random.randint(1, 5))],
# JSON field
"json_field": {
"name": f"item_{i}",
"value": random.randint(1, 1000),
"tags": [f"tag_{j}" for j in range(random.randint(1, 3))],
"metadata": {
"created": f"2024-01-{random.randint(1, 28):02d}",
"score": random.uniform(0.0, 100.0),
},
},
}
data.append(record)
return data
@staticmethod
def generate_comprehensive_test_data_alt(count: int = 100) -> list[dict[str, Any]]:
"""Generate comprehensive test data with alternative vector types (BFLOAT16 + INT8)."""
# Generate vectors using standard method - alternative set
float_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.FLOAT_VECTOR)
bfloat16_vectors = TestCDCSyncBase._gen_vectors(count, 64, DataType.BFLOAT16_VECTOR)
int8_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.INT8_VECTOR)
sparse_vectors = TestCDCSyncBase._gen_vectors(count, 1000, DataType.SPARSE_FLOAT_VECTOR)
data = []
for i in range(count):
record = {
# Vector fields (alternative set - limited to 4 total)
"float_vector": float_vectors[i],
"bfloat16_vector": bfloat16_vectors[i],
"int8_vector": int8_vectors[i],
"sparse_vector": sparse_vectors[i],
# Scalar fields
"bool_field": random.choice([True, False]),
"int8_field": random.randint(-128, 127),
"int16_field": random.randint(-32768, 32767),
"int32_field": random.randint(-2147483648, 2147483647),
"int64_field": random.randint(-9223372036854775808, 9223372036854775807),
"float_field": random.uniform(-1000.0, 1000.0),
"double_field": random.uniform(-1000.0, 1000.0),
"varchar_field": f"test_varchar_{i}_{random.randint(1000, 9999)}",
# Array fields
"bool_array": [random.choice([True, False]) for _ in range(random.randint(1, 10))],
"int32_array": [random.randint(-100, 100) for _ in range(random.randint(1, 10))],
"int64_array": [random.randint(-1000, 1000) for _ in range(random.randint(1, 10))],
"float_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"double_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"varchar_array": [f"array_str_{j}" for j in range(random.randint(1, 5))],
# JSON field
"json_field": {
"name": f"item_{i}",
"value": random.randint(1, 1000),
"tags": [f"tag_{j}" for j in range(random.randint(1, 3))],
"metadata": {
"created": f"2024-01-{random.randint(1, 28):02d}",
"score": random.uniform(0.0, 100.0),
},
},
}
data.append(record)
return data
@staticmethod
def generate_comprehensive_test_data_alt_with_id(count: int = 100, start_id: int = 0) -> list[dict[str, Any]]:
"""Generate comprehensive test data with manual IDs and alternative vector types (BFLOAT16 + INT8)."""
# Generate vectors using standard method - alternative set
float_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.FLOAT_VECTOR)
bfloat16_vectors = TestCDCSyncBase._gen_vectors(count, 64, DataType.BFLOAT16_VECTOR)
int8_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.INT8_VECTOR)
sparse_vectors = TestCDCSyncBase._gen_vectors(count, 1000, DataType.SPARSE_FLOAT_VECTOR)
data = []
for i in range(count):
record = {
"id": start_id + i,
# Vector fields (alternative set - limited to 4 total)
"float_vector": float_vectors[i],
"bfloat16_vector": bfloat16_vectors[i],
"int8_vector": int8_vectors[i],
"sparse_vector": sparse_vectors[i],
# Scalar fields
"bool_field": random.choice([True, False]),
"int8_field": random.randint(-128, 127),
"int16_field": random.randint(-32768, 32767),
"int32_field": random.randint(-2147483648, 2147483647),
"int64_field": random.randint(-9223372036854775808, 9223372036854775807),
"float_field": random.uniform(-1000.0, 1000.0),
"double_field": random.uniform(-1000.0, 1000.0),
"varchar_field": f"test_varchar_{i}_{random.randint(1000, 9999)}",
# Array fields
"bool_array": [random.choice([True, False]) for _ in range(random.randint(1, 10))],
"int32_array": [random.randint(-100, 100) for _ in range(random.randint(1, 10))],
"int64_array": [random.randint(-1000, 1000) for _ in range(random.randint(1, 10))],
"float_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"double_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"varchar_array": [f"array_str_{j}" for j in range(random.randint(1, 5))],
# JSON field
"json_field": {
"name": f"item_{i}",
"value": random.randint(1, 1000),
"tags": [f"tag_{j}" for j in range(random.randint(1, 3))],
"metadata": {
"created": f"2024-01-{random.randint(1, 28):02d}",
"score": random.uniform(0.0, 100.0),
},
},
}
data.append(record)
return data
@staticmethod
def _gen_vectors(nb, dim, vector_data_type):
"""Generate vectors using standard Milvus testing methods."""
import numpy as np
vectors = []
if vector_data_type == DataType.FLOAT_VECTOR:
vectors = [[random.uniform(-1, 1) for _ in range(dim)] for _ in range(nb)]
elif vector_data_type == DataType.FLOAT16_VECTOR:
# Generate float16 vectors like common_func.py
for _ in range(nb):
raw_vector = [random.random() for _ in range(dim)]
fp16_vector = np.array(raw_vector, dtype=np.float16)
vectors.append(fp16_vector)
elif vector_data_type == DataType.BFLOAT16_VECTOR:
# Generate bfloat16 vectors like common_func.py
from ml_dtypes import bfloat16
for _ in range(nb):
raw_vector = [random.random() for _ in range(dim)]
bf16_vector = np.array(raw_vector, dtype=bfloat16)
vectors.append(bf16_vector)
elif vector_data_type == DataType.INT8_VECTOR:
# Generate int8 vectors like common_func.py
for _ in range(nb):
raw_vector = [random.randint(-128, 127) for _ in range(dim)]
int8_vector = np.array(raw_vector, dtype=np.int8)
vectors.append(int8_vector)
elif vector_data_type == DataType.BINARY_VECTOR:
# Generate binary vectors (dim bits = dim/8 bytes)
bytes_per_vector = dim // 8
for _ in range(nb):
binary_vec = np.random.randint(0, 256, size=bytes_per_vector, dtype=np.uint8).tobytes()
vectors.append(binary_vec)
elif vector_data_type == DataType.SPARSE_FLOAT_VECTOR:
# Sparse vectors have no fixed dim in schema; dim=0 is passed through
# from parametrized callers. Use a fixed pool so we always have a
# valid range for randint(5, ...).
pool = dim if dim >= 20 else 1000
for _ in range(nb):
sparse_indices = random.sample(range(pool), random.randint(5, 20))
sparse_values = [random.random() for _ in sparse_indices]
sparse_vector = {idx: val for idx, val in zip(sparse_indices, sparse_values)}
vectors.append(sparse_vector)
return vectors
@staticmethod
def generate_comprehensive_test_data_with_id(count: int = 100, start_id: int = 0) -> list[dict[str, Any]]:
"""Generate comprehensive test data with manual IDs for upsert operations using standard vector generation."""
# Generate vectors using standard method
float_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.FLOAT_VECTOR)
float16_vectors = TestCDCSyncBase._gen_vectors(count, 64, DataType.FLOAT16_VECTOR)
binary_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.BINARY_VECTOR)
sparse_vectors = TestCDCSyncBase._gen_vectors(count, 1000, DataType.SPARSE_FLOAT_VECTOR)
data = []
for i in range(count):
record = {
"id": start_id + i,
# Vector fields (limited to 4 total)
"float_vector": float_vectors[i],
"float16_vector": float16_vectors[i],
"binary_vector": binary_vectors[i],
"sparse_vector": sparse_vectors[i],
# Scalar fields
"bool_field": random.choice([True, False]),
"int8_field": random.randint(-128, 127),
"int16_field": random.randint(-32768, 32767),
"int32_field": random.randint(-2147483648, 2147483647),
"int64_field": random.randint(-9223372036854775808, 9223372036854775807),
"float_field": random.uniform(-1000.0, 1000.0),
"double_field": random.uniform(-1000.0, 1000.0),
"varchar_field": f"test_varchar_{i}_{random.randint(1000, 9999)}",
# Array fields
"bool_array": [random.choice([True, False]) for _ in range(random.randint(1, 10))],
"int32_array": [random.randint(-100, 100) for _ in range(random.randint(1, 10))],
"int64_array": [random.randint(-1000, 1000) for _ in range(random.randint(1, 10))],
"float_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"double_array": [random.uniform(-100.0, 100.0) for _ in range(random.randint(1, 10))],
"varchar_array": [f"array_str_{j}" for j in range(random.randint(1, 5))],
# JSON field
"json_field": {
"name": f"item_{i}",
"value": random.randint(1, 1000),
"tags": [f"tag_{j}" for j in range(random.randint(1, 3))],
"metadata": {
"created": f"2024-01-{random.randint(1, 28):02d}",
"score": random.uniform(0.0, 100.0),
},
},
}
data.append(record)
return data
@staticmethod
def generate_bfloat16_test_data(count: int = 100) -> list[dict[str, Any]]:
"""Generate test data with BFLOAT16_VECTOR for index testing using standard method."""
# Generate bfloat16 vectors using standard method
bfloat16_vectors = TestCDCSyncBase._gen_vectors(count, 64, DataType.BFLOAT16_VECTOR)
data = []
for i in range(count):
record = {
"bfloat16_vector": bfloat16_vectors[i],
}
data.append(record)
return data
@staticmethod
def generate_int8_test_data(count: int = 100) -> list[dict[str, Any]]:
"""Generate test data with INT8_VECTOR for index testing using standard method."""
# Generate int8 vectors using standard method
int8_vectors = TestCDCSyncBase._gen_vectors(count, 128, DataType.INT8_VECTOR)
data = []
for i in range(count):
record = {
"int8_vector": int8_vectors[i],
}
data.append(record)
return data
def cleanup_database(self, client: MilvusClient, db_name: str):
"""Clean up database if exists."""
try:
if db_name in client.list_databases():
logger.info(f"[CLEANUP] Cleaning up database: {db_name}")
client.drop_database(db_name)
logger.info(f"[SUCCESS] Database {db_name} cleaned up successfully")
else:
logger.debug(f"Database {db_name} does not exist, skipping cleanup")
except Exception as e:
logger.warning(f"[FAILED] Failed to cleanup database {db_name}: {e}")
def cleanup_collection(self, client: MilvusClient, collection_name: str):
"""Clean up collection if exists."""
try:
if client.has_collection(collection_name):
logger.info(f"[CLEANUP] Cleaning up collection: {collection_name}")
client.drop_collection(collection_name)
logger.info(f"[SUCCESS] Collection {collection_name} cleaned up successfully")
else:
logger.debug(f"Collection {collection_name} does not exist, skipping cleanup")
except Exception as e:
logger.warning(f"[FAILED] Failed to cleanup collection {collection_name}: {e}")
def cleanup_user(self, client: MilvusClient, username: str):
"""Clean up user if exists."""
try:
users = client.list_users()
if username in users:
logger.info(f"[CLEANUP] Cleaning up user: {username}")
client.drop_user(username)
logger.info(f"[SUCCESS] User {username} cleaned up successfully")
else:
logger.debug(f"User {username} does not exist, skipping cleanup")
except Exception as e:
logger.warning(f"[FAILED] Failed to cleanup user {username}: {e}")
def cleanup_role(self, client: MilvusClient, role_name: str):
"""Clean up role if exists."""
try:
roles = client.list_roles()
if role_name in roles:
logger.info(f"[CLEANUP] Cleaning up role: {role_name}")
# First, revoke all privileges from the role
try:
role_privileges = client.describe_role(role_name)
if isinstance(role_privileges, dict) and "privileges" in role_privileges:
privileges_list = role_privileges["privileges"]
elif isinstance(role_privileges, list):
privileges_list = role_privileges
else:
privileges_list = []
for privilege_info in privileges_list:
try:
client.revoke_privilege(
role_name=role_name,
object_type=privilege_info.get("object_type", "Collection"),
privilege=privilege_info.get("privilege"),
object_name=privilege_info.get("object_name", "*"),
)
logger.debug(
f"[CLEANUP] Revoked privilege {privilege_info.get('privilege')} from role {role_name}"
)
except Exception as revoke_e:
logger.debug(
f"[CLEANUP] Failed to revoke privilege {privilege_info.get('privilege')}: {revoke_e}"
)
except Exception as describe_e:
logger.debug(f"[CLEANUP] Failed to describe role privileges: {describe_e}")
# Then drop the role
client.drop_role(role_name)
logger.info(f"[SUCCESS] Role {role_name} cleaned up successfully")
else:
logger.debug(f"Role {role_name} does not exist, skipping cleanup")
except Exception as e:
logger.warning(f"[FAILED] Failed to cleanup role {role_name}: {e}")
def log_test_start(self, test_name: str, operation_type: str, resource_name: str = ""):
"""Log test case start with detailed information."""
separator = "=" * 80
logger.info(f"\n{separator}")
logger.info(f"[TEST_START] Starting test: {test_name}")
logger.info(f"[OPERATION] Operation type: {operation_type}")
if resource_name:
logger.info(f"[RESOURCE] Resource: {resource_name}")
logger.info(f"[TIME] Start time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"{separator}")
def log_test_end(self, test_name: str, success: bool, duration: float = 0):
"""Log test case completion with result."""
separator = "=" * 80
status = "PASSED" if success else "FAILED"
logger.info(f"\n{separator}")
logger.info(f"[TEST_END] Test completed: {test_name}")
logger.info(f"[RESULT] Result: {status}")
if duration > 0:
logger.info(f"[DURATION] Duration: {duration:.2f}s")
logger.info(f"[TIME] End time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f"{separator}\n")
def log_operation(
self,
operation: str,
resource_type: str,
resource_name: str,
client_type: str = "upstream",
):
"""Log CDC operation execution."""
logger.info(f"[EXECUTE] Executing {operation} on {client_type}: {resource_type} '{resource_name}'")
def log_sync_verification(self, operation: str, resource_name: str, expected_state: str):
"""Log sync verification attempt."""
logger.info(f"[VERIFY] Verifying sync for {operation}: {resource_name} should be {expected_state}")
def log_data_operation(self, operation: str, collection_name: str, count: int = 0, details: str = ""):
"""Log data manipulation operations."""
if count > 0:
logger.info(f"[DATA] {operation} operation: {collection_name} - {count} records {details}")
else:
logger.info(f"[DATA] {operation} operation: {collection_name} {details}")
def log_resource_state(
self,
resource_type: str,
resource_name: str,
state: str,
client_type: str,
details: str = "",
):
"""Log current resource state."""
state_prefix = "[EXISTS]" if state == "exists" else "[MISSING]" if state == "missing" else "[UNKNOWN]"
logger.info(f"{state_prefix} {client_type.capitalize()} {resource_type} '{resource_name}': {state} {details}")
# -------------------------------------------------------------------------
# Part 1: Additional Schema Factories
# -------------------------------------------------------------------------
@staticmethod
def create_fts_schema(client, analyzer_type="standard"):
"""Create schema with BM25 full-text search function."""
from pymilvus import Function, FunctionType
schema = client.create_schema()
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field(
"text_field",
DataType.VARCHAR,
max_length=2048,
enable_analyzer=True,
analyzer_params={"type": analyzer_type},
)
schema.add_field("sparse_output", DataType.SPARSE_FLOAT_VECTOR, is_function_output=True)
schema.add_field("dense_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("category", DataType.VARCHAR, max_length=64)
bm25_fn = Function(
name="bm25_fn",
function_type=FunctionType.BM25,
input_field_names=["text_field"],
output_field_names=["sparse_output"],
)
schema.add_function(bm25_fn)
return schema
@staticmethod
def create_dynamic_schema(client):
"""Create schema with dynamic field enabled."""
schema = client.create_schema(enable_dynamic_field=True)
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("varchar_field", DataType.VARCHAR, max_length=256)
return schema
@staticmethod
def create_nullable_schema(client):
"""Create schema with nullable fields."""
schema = client.create_schema()
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("nullable_int64", DataType.INT64, nullable=True)
schema.add_field("nullable_varchar", DataType.VARCHAR, max_length=256, nullable=True)
schema.add_field("nullable_float", DataType.FLOAT, nullable=True)
schema.add_field("nullable_json", DataType.JSON, nullable=True)
schema.add_field(
"nullable_array",
DataType.ARRAY,
element_type=DataType.INT32,
max_capacity=100,
nullable=True,
)
return schema
@staticmethod
def create_default_values_schema(client):
"""Create schema with default values on scalar fields."""
schema = client.create_schema()
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("default_int64", DataType.INT64, default_value=0)
schema.add_field("default_varchar", DataType.VARCHAR, max_length=256, default_value="default")
schema.add_field("default_float", DataType.FLOAT, default_value=1.0)
schema.add_field("default_bool", DataType.BOOL, default_value=False)
return schema
@staticmethod
def create_partition_key_schema(client, key_type="VarChar"):
"""Create schema with a partition key field."""
schema = client.create_schema()
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
if key_type == "VarChar":
schema.add_field(
"partition_key_field",
DataType.VARCHAR,
max_length=256,
is_partition_key=True,
)
else:
schema.add_field(
"partition_key_field",
DataType.INT64,
is_partition_key=True,
)
schema.add_field("data_field", DataType.VARCHAR, max_length=256)
return schema
@staticmethod
def create_clustering_key_schema(client):
"""Create schema with a clustering key field."""
schema = client.create_schema()
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=128)
schema.add_field("clustering_key_field", DataType.INT64, is_clustering_key=True)
schema.add_field("data_field", DataType.VARCHAR, max_length=256)
return schema
@staticmethod
def create_single_vector_schema(client, vector_type="FLOAT_VECTOR", dim=128):
"""Create a simple single-vector schema for a given vector type."""
schema = client.create_schema()
schema.add_field("id", DataType.INT64, is_primary=True, auto_id=True)
dtype = getattr(DataType, vector_type)
if vector_type == "SPARSE_FLOAT_VECTOR":
schema.add_field("vector", dtype)
else:
schema.add_field("vector", dtype, dim=dim)
schema.add_field("int_field", DataType.INT64)
schema.add_field("varchar_field", DataType.VARCHAR, max_length=256)
schema.add_field("float_field", DataType.FLOAT)
return schema
# -------------------------------------------------------------------------
# Part 2: Additional Data Generators
# -------------------------------------------------------------------------
@classmethod
def generate_fts_data(cls, count=100) -> list[dict[str, Any]]:
"""Generate data for full-text search (FTS) collections."""
categories = ["catA", "catB", "catC", "catD"]
data = []
for i in range(count):
base_sentence = cls.FTS_SENTENCES[i % len(cls.FTS_SENTENCES)]
record = {
"text_field": f"{base_sentence} unique_{i}_{random.randint(10000, 99999)}",
"dense_vector": [random.random() for _ in range(128)],
"category": random.choice(categories),
}
data.append(record)
return data
@staticmethod
def generate_nullable_data(count=100, null_ratio=0.3) -> list[dict[str, Any]]:
"""Generate data with randomly null-ified nullable fields."""
data = []
for _ in range(count):
record: dict[str, Any] = {
"float_vector": [random.random() for _ in range(128)],
"nullable_int64": None if random.random() < null_ratio else random.randint(-1000, 1000),
"nullable_varchar": None if random.random() < null_ratio else f"varchar_{random.randint(1000, 9999)}",
"nullable_float": None if random.random() < null_ratio else random.uniform(-100.0, 100.0),
"nullable_json": None if random.random() < null_ratio else {"key": random.randint(1, 100)},
"nullable_array": None
if random.random() < null_ratio
else [random.randint(0, 100) for _ in range(random.randint(1, 10))],
}
data.append(record)
return data
@staticmethod
def generate_dynamic_data(count=100, extra_fields=None) -> list[dict[str, Any]]:
"""Generate data for collections with dynamic fields enabled."""
if extra_fields is None:
extra_fields = {"extra_int": int, "extra_str": str, "extra_float": float}
data = []
for i in range(count):
record: dict[str, Any] = {
"float_vector": [random.random() for _ in range(128)],
"varchar_field": f"dynamic_varchar_{i}_{random.randint(1000, 9999)}",
}
for field_name, field_type in extra_fields.items():
if field_type is int:
record[field_name] = random.randint(0, 10000)
elif field_type is str:
record[field_name] = f"{field_name}_val_{random.randint(1000, 9999)}"
elif field_type is float:
record[field_name] = random.uniform(0.0, 1000.0)
else:
record[field_name] = None
data.append(record)
return data
@classmethod
def generate_single_vector_data(cls, count=100, vector_type="FLOAT_VECTOR", dim=128) -> list[dict[str, Any]]:
"""Generate data for a single-vector collection."""
dtype = getattr(DataType, vector_type)
vectors = cls._gen_vectors(count, dim, dtype)
data = []
for i, vec in enumerate(vectors):
record = {
"vector": vec,
"int_field": random.randint(0, 1000),
"varchar_field": f"single_vec_{i}_{random.randint(1000, 9999)}",
"float_field": random.uniform(0.0, 1000.0),
}
data.append(record)
return data
# -------------------------------------------------------------------------
# Part 3: Verification Helpers
# -------------------------------------------------------------------------
@classmethod
def verify_data_sampling(
cls,
upstream_client,
downstream_client,
collection_name: str,
sample_ratio: float = 0.2,
output_fields: list[str] = None,
):
"""
Sample a fraction of records and compare them field-by-field between upstream
and downstream.
Returns:
(match_count, mismatch_count, mismatch_details)
"""
# Retrieve PKs from upstream. Milvus requires a limit when filter is empty.
all_up_results = upstream_client.query(
collection_name=collection_name,
filter="",
output_fields=["id"],
limit=16384,
)
all_pks = [r["id"] for r in all_up_results]
if not all_pks:
logger.warning(f"[VERIFY] No records found in upstream collection '{collection_name}'")
return 0, 0, []
sample_size = max(1, int(len(all_pks) * sample_ratio))
sampled_pks = random.sample(all_pks, min(sample_size, len(all_pks)))
logger.info(f"[VERIFY] Sampling {len(sampled_pks)} / {len(all_pks)} records from '{collection_name}'")
match_count = 0
mismatch_count = 0
mismatch_details: list[dict[str, Any]] = []
for pk in sampled_pks:
up_rows = upstream_client.query(
collection_name=collection_name,
filter=f"id == {pk}",
output_fields=output_fields or ["*"],
)
down_rows = downstream_client.query(
collection_name=collection_name,
filter=f"id == {pk}",
output_fields=output_fields or ["*"],
)
if not up_rows or not down_rows:
mismatch_count += 1
mismatch_details.append({"pk": pk, "reason": "missing on one side"})
continue
up_row = up_rows[0]
down_row = down_rows[0]
fields_match = True
field_diffs: dict[str, Any] = {}
for field, up_val in up_row.items():
down_val = down_row.get(field)
if isinstance(up_val, float) or isinstance(down_val, float):
try:
if abs(float(up_val) - float(down_val)) > 1e-6:
fields_match = False
field_diffs[field] = {"upstream": up_val, "downstream": down_val}
except (TypeError, ValueError):
if up_val != down_val:
fields_match = False
field_diffs[field] = {"upstream": up_val, "downstream": down_val}
else:
if up_val != down_val:
fields_match = False
field_diffs[field] = {"upstream": up_val, "downstream": down_val}
if fields_match:
match_count += 1
else:
mismatch_count += 1
mismatch_details.append({"pk": pk, "field_diffs": field_diffs})
logger.info(f"[VERIFY] Sampling result — match: {match_count}, mismatch: {mismatch_count}")
return match_count, mismatch_count, mismatch_details
@classmethod
def verify_search_consistency(
cls,
upstream_client,
downstream_client,
collection_name: str,
vectors: list[Any],
anns_field: str,
limit: int = 10,
metric_type: str = "COSINE",
):
"""
Run search queries on both sides and compute PK overlap ratios.
Returns:
(avg_overlap_ratio, all_upstream_pks, all_downstream_pks)
"""
all_upstream_pks: list[list[Any]] = []
all_downstream_pks: list[list[Any]] = []
overlap_ratios: list[float] = []
search_params = {"metric_type": metric_type}
for vec in vectors:
up_results = upstream_client.search(
collection_name=collection_name,
data=[vec],
anns_field=anns_field,
search_params=search_params,
limit=limit,
output_fields=["id"],
)
down_results = downstream_client.search(
collection_name=collection_name,
data=[vec],
anns_field=anns_field,
search_params=search_params,
limit=limit,
output_fields=["id"],
)
up_pks = set(hit["id"] for hit in up_results[0]) if up_results else set()
down_pks = set(hit["id"] for hit in down_results[0]) if down_results else set()
all_upstream_pks.append(list(up_pks))
all_downstream_pks.append(list(down_pks))
union_size = len(up_pks | down_pks)
if union_size > 0:
overlap = len(up_pks & down_pks) / union_size
else:
overlap = 1.0
overlap_ratios.append(overlap)
avg_overlap = sum(overlap_ratios) / len(overlap_ratios) if overlap_ratios else 0.0
logger.info(
f"[VERIFY] Search consistency for '{collection_name}': avg_overlap={avg_overlap:.4f} "
f"over {len(vectors)} queries"
)
return avg_overlap, all_upstream_pks, all_downstream_pks
@staticmethod
def verify_query_consistency(
upstream_client,
downstream_client,
collection_name: str,
filter_expr: str,
output_fields: list[str] = None,
):
"""
Query both upstream and downstream with the same filter and compare PK sets.
Returns:
(up_count, down_count, overlap_count, extra_up, extra_down)
"""
up_results = upstream_client.query(
collection_name=collection_name,
filter=filter_expr,
output_fields=output_fields or ["id"],
)
down_results = downstream_client.query(
collection_name=collection_name,
filter=filter_expr,
output_fields=output_fields or ["id"],
)
up_pks = set(r["id"] for r in up_results)
down_pks = set(r["id"] for r in down_results)
overlap = up_pks & down_pks
extra_up = up_pks - down_pks
extra_down = down_pks - up_pks
logger.info(
f"[VERIFY] Query consistency for '{collection_name}' filter='{filter_expr}': "
f"upstream={len(up_pks)}, downstream={len(down_pks)}, "
f"overlap={len(overlap)}, extra_up={len(extra_up)}, extra_down={len(extra_down)}"
)
return len(up_pks), len(down_pks), len(overlap), extra_up, extra_down
@staticmethod
def verify_iterator_consistency(
upstream_client,
downstream_client,
collection_name: str,
batch_size: int = 100,
):
"""
Iterate through all records on both sides and compare PK sets.
Returns:
(up_count, down_count, match_bool)
"""
def _collect_pks(client):
pks = set()
iterator = client.query_iterator(
collection_name=collection_name,
batch_size=batch_size,
output_fields=["id"],
)
while True:
batch = iterator.next()
if not batch:
iterator.close()
break
for row in batch:
pks.add(row["id"])
return pks
up_pks = _collect_pks(upstream_client)
down_pks = _collect_pks(downstream_client)
match = up_pks == down_pks
logger.info(
f"[VERIFY] Iterator consistency for '{collection_name}': "
f"upstream={len(up_pks)}, downstream={len(down_pks)}, match={match}"
)
return len(up_pks), len(down_pks), match