430 lines
15 KiB
Python
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)}"
|
|
)
|