Files
alibaba--zvec/python/tests/detail/test_collection_concurrency.py
T
2026-07-13 12:47:42 +08:00

430 lines
15 KiB
Python

# Copyright 2025-present the zvec project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import pytest
import threading
import numpy as np
import zvec
from zvec import (
CollectionOption,
InvertIndexParam,
HnswIndexParam,
Collection,
Doc,
DataType,
FieldSchema,
VectorSchema,
)
class TestCollectionConcurrency:
@pytest.fixture(scope="function")
def test_collection(self, tmp_path_factory):
"""Fixture to create a test collection"""
collection_schema = zvec.CollectionSchema(
name="test_collection",
fields=[
FieldSchema(
"id",
DataType.INT64,
nullable=False,
index_param=InvertIndexParam(enable_range_optimization=True),
),
FieldSchema(
"name",
DataType.STRING,
nullable=False,
index_param=InvertIndexParam(),
),
FieldSchema("weight", DataType.FLOAT, nullable=True),
],
vectors=[
VectorSchema(
"dense",
DataType.VECTOR_FP32,
dimension=128,
index_param=HnswIndexParam(),
),
VectorSchema(
"sparse", DataType.SPARSE_VECTOR_FP32, index_param=HnswIndexParam()
),
],
)
collection_option = CollectionOption(read_only=False, enable_mmap=True)
temp_dir = tmp_path_factory.mktemp("zvec")
collection_path = temp_dir / "test_collection"
coll = zvec.create_and_open(
path=str(collection_path),
schema=collection_schema,
option=collection_option,
)
assert coll is not None, "Failed to create and open collection"
yield coll
# Clean up
if hasattr(coll, "destroy") and coll is not None:
try:
coll.destroy()
except Exception as e:
print(f"Warning: failed to destroy collection: {e}")
def test_concurrent_read_write(self, test_collection: Collection):
results = []
def insert_docs(thread_id):
try:
docs = [
Doc(
id=f"{thread_id}_{i}",
fields={
"id": int(f"{thread_id}{i}"),
"name": f"thread_{thread_id}_doc_{i}",
"weight": float(i),
},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(i), 2: float(i * 2)},
},
)
for i in range(5)
]
result = test_collection.insert(docs)
results.append((thread_id, "insert", len(result)))
except Exception as e:
results.append((thread_id, "insert_exception", str(e)))
def query_docs(thread_id):
try:
result = test_collection.query(filter="id > 0", topk=10)
results.append((thread_id, "query", len(result)))
except Exception as e:
results.append((thread_id, "query_exception", str(e)))
# Create threads for concurrent operations
threads = []
# Start insert threads
for i in range(3):
thread = threading.Thread(target=insert_docs, args=(i,))
threads.append(thread)
thread.start()
# Start query threads
for i in range(3):
thread = threading.Thread(target=query_docs, args=(i,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Analyze results
insert_results = [r for r in results if r[1] == "insert"]
query_results = [r for r in results if r[1] == "query"]
logging.info(
f"Concurrent read/write results - Inserts: {len(insert_results)}, Queries: {len(query_results)}"
)
# At least some operations should succeed
assert len(insert_results) + len(query_results) > 0
def test_concurrent_query(self, test_collection: Collection):
# First insert some data
docs = [
Doc(
id=f"{i}",
fields={"id": i, "name": f"test_{i}", "weight": float(i)},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(i), 2: float(i * 2)},
},
)
for i in range(20)
]
insert_result = test_collection.insert(docs)
assert len(insert_result) == 20
results = []
def query_operation(thread_id):
"""Perform query operation from a thread"""
try:
result = test_collection.query(filter=f"id > {thread_id}", topk=5)
results.append((thread_id, "query", len(result)))
except Exception as e:
results.append((thread_id, "query_exception", str(e)))
# Create multiple threads for concurrent queries
threads = []
for i in range(5):
thread = threading.Thread(target=query_operation, args=(i,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Analyze results
query_results = [r for r in results if r[1] == "query"]
logging.info(f"Concurrent query results - Queries: {len(query_results)}")
# All query operations should succeed
assert len(query_results) == 5
def test_concurrent_modifications(self, test_collection: Collection):
# First insert some data
docs = [
Doc(
id=f"{i}",
fields={"id": i, "name": f"test_{i}", "weight": float(i)},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(i), 2: float(i * 2)},
},
)
for i in range(10)
]
insert_result = test_collection.insert(docs)
assert len(insert_result) == 10
results = []
def update_operation(thread_id):
"""Perform update operation from a thread"""
try:
# Each thread updates different documents
update_docs = [
Doc(
id=f"{i}",
fields={
"id": i,
"name": f"updated_by_thread_{thread_id}",
"weight": float(i + thread_id),
},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(i) + 0.5, 2: float(i * 2) + 0.5},
},
)
for i in range(thread_id * 2, thread_id * 2 + 2)
]
result = test_collection.update(update_docs)
results.append((thread_id, "update", len(result)))
except Exception as e:
results.append((thread_id, "update_exception", str(e)))
def delete_operation(thread_id):
"""Perform delete operation from a thread"""
try:
# Each thread deletes different documents
delete_ids = [f"{thread_id * 2 + 2}", f"{thread_id * 2 + 3}"]
result = test_collection.delete(delete_ids)
results.append((thread_id, "delete", len(result)))
except Exception as e:
results.append((thread_id, "delete_exception", str(e)))
# Create threads for concurrent operations
threads = []
# Start update threads
for i in range(3):
thread = threading.Thread(target=update_operation, args=(i,))
threads.append(thread)
thread.start()
# Start delete threads
for i in range(2):
thread = threading.Thread(target=delete_operation, args=(i,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Analyze results
update_results = [r for r in results if r[1] == "update"]
delete_results = [r for r in results if r[1] == "delete"]
logging.info(
f"Concurrent modification results - Updates: {len(update_results)}, Deletes: {len(delete_results)}"
)
# At least some operations should succeed
assert len(update_results) + len(delete_results) > 0
def test_read_write_locking(self, test_collection: Collection):
# Perform operations that should be thread-safe
docs = [
Doc(
id=f"{i}",
fields={"id": i, "name": f"test_{i}", "weight": float(i)},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(i), 2: float(i * 2)},
},
)
for i in range(5)
]
# Insert data
insert_result = test_collection.insert(docs)
assert len(insert_result) == 5
# Concurrent operations should not cause data corruption
results = []
def mixed_operation(thread_id):
"""Perform mixed operations from a thread"""
try:
# Mix of read and write operations
if thread_id % 2 == 0:
# Read operation
result = test_collection.fetch([f"{thread_id % 5}"])
results.append((thread_id, "read", len(result)))
else:
# Write operation
doc = Doc(
id=f"{thread_id % 5}",
fields={
"id": thread_id % 5,
"name": f"mixed_op_{thread_id}",
"weight": float(thread_id),
},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(thread_id), 2: float(thread_id * 2)},
},
)
result = test_collection.upsert(doc)
results.append((thread_id, "write", len(result)))
except Exception as e:
results.append((thread_id, "exception", str(e)))
# Create multiple threads
threads = []
for i in range(10):
thread = threading.Thread(target=mixed_operation, args=(i,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Verify that the collection is still in a consistent state
final_result = test_collection.query()
assert len(final_result) >= 0 # Should not crash or return corrupted data
def test_race_condition_detection(self, test_collection: Collection):
# Insert initial data
docs = [
Doc(
id=f"{i}",
fields={"id": i, "name": f"initial_{i}", "weight": float(i)},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(i), 2: float(i * 2)},
},
)
for i in range(10)
]
insert_result = test_collection.insert(docs)
assert len(insert_result) == 10
# Perform many rapid concurrent operations
operation_count = 100
results = []
def rapid_operation(op_id):
"""Perform rapid operations"""
try:
# Alternate between different types of operations
if op_id % 4 == 0:
# Insert
doc = Doc(
id=f"rapid_{op_id}",
fields={
"id": op_id,
"name": f"rapid_{op_id}",
"weight": float(op_id),
},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(op_id), 2: float(op_id * 2)},
},
)
result = test_collection.insert(doc)
results.append(("insert", len(result)))
elif op_id % 4 == 1:
# Update
doc = Doc(
id=f"{op_id % 10}",
fields={
"id": op_id % 10,
"name": f"rapid_update_{op_id}",
"weight": float(op_id),
},
vectors={
"dense": np.random.random(128).tolist(),
"sparse": {1: float(op_id), 2: float(op_id * 2)},
},
)
result = test_collection.update(doc)
results.append(("update", len(result)))
elif op_id % 4 == 2:
# Query
result = test_collection.query(filter=f"id > {op_id % 5}", topk=3)
results.append(("query", len(result)))
else:
# Fetch
result = test_collection.fetch([f"{op_id % 10}"])
results.append(("fetch", len(result)))
except Exception as e:
results.append(("exception", str(e)))
# Create many threads for rapid concurrent operations
threads = []
for i in range(operation_count):
thread = threading.Thread(target=rapid_operation, args=(i,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Verify collection is still functional
final_query = test_collection.query()
assert len(final_query) >= 0 # Should not be corrupted
logging.info(
f"Rapid concurrent operations completed - Total operations: {len(results)}"
)