Files
qdrant/tests/openapi/test_named_vector_crud.py
Arnaud Gourlay 89aedca29b Fix Openapi spec for new vector crud ops (#8779)
* Fix Openapi spec for new vector crud ops

* validate spec conformance in tests
2026-05-08 13:47:21 +02:00

301 lines
9.5 KiB
Python

import pytest
import time
import requests
from .helpers.helpers import request_with_validation
from .helpers.settings import QDRANT_HOST
VECTOR_SIZE1 = 4
VECTOR_SIZE2 = 8
@pytest.fixture(autouse=True, scope="module")
def setup(collection_name):
# Drop if exists
request_with_validation(
api='/collections/{collection_name}',
method="DELETE",
path_params={'collection_name': collection_name},
)
# Create collection with two named vectors
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': collection_name},
body={
"vectors": {
"vec_a": {"size": VECTOR_SIZE1, "distance": "Cosine"},
"vec_b": {"size": VECTOR_SIZE2, "distance": "Cosine"},
},
"optimizers_config": {
"indexing_threshold": 1,
},
},
)
assert response.ok
yield
request_with_validation(
api='/collections/{collection_name}',
method="DELETE",
path_params={'collection_name': collection_name},
)
def test_delete_recreate_vector_scroll(collection_name):
"""
Deleting a named vector and recreating it must not break scroll.
Reproduces a bug where EmptyDenseVectorStorage on immutable segments
has total_vector_count=0 while points exist, causing a panic
in vectors_by_offsets during scroll/retrieve.
"""
# Upsert points with both vectors
points = [
{
"id": i,
"vector": {
"vec_a": [float(x) / 100 for x in range(VECTOR_SIZE1)],
"vec_b": [float(x) / 100 for x in range(VECTOR_SIZE2)],
},
"payload": {"idx": i},
}
for i in range(200)
]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': collection_name},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
# Delete vec_a
response = request_with_validation(
api='/collections/{collection_name}/vectors/{vector_name}',
method="DELETE",
path_params={'collection_name': collection_name, 'vector_name': 'vec_a'},
query_params={'wait': 'true'},
)
assert response.ok
# Recreate vec_a with different dimensions
response = request_with_validation(
api='/collections/{collection_name}/vectors/{vector_name}',
method="PUT",
path_params={'collection_name': collection_name, 'vector_name': 'vec_a'},
query_params={'wait': 'true'},
body={"dense": {"size": 6, "distance": "Dot"}},
)
assert response.ok
# Scroll with vectors — must not panic
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': collection_name},
body={"limit": 10, "with_vector": True},
)
assert response.ok
result = response.json()['result']
assert len(result['points']) == 10
for point in result['points']:
assert 'vec_b' in point['vector'], f"vec_b missing from point {point['id']}"
assert len(point['vector']['vec_b']) == VECTOR_SIZE2
# Input-validation tests below intentionally use plain `requests` because
# `request_with_validation` would reject the body client-side (size violates
# the spec's minimum/maximum), preventing the server-side rejection from
# being exercised.
def test_create_vector_rejects_zero_size(collection_name):
"""size: 0 must be rejected at the API boundary, not reach the storage layer."""
response = requests.put(
f"{QDRANT_HOST}/collections/{collection_name}/vectors/vec_zero",
params={'wait': 'true'},
json={"dense": {"size": 0, "distance": "Cosine"}},
)
assert response.status_code == 422, response.text
assert "size" in response.text
def test_create_vector_rejects_oversize(collection_name):
"""size above 65536 must be rejected at the API boundary."""
response = requests.put(
f"{QDRANT_HOST}/collections/{collection_name}/vectors/vec_huge",
params={'wait': 'true'},
json={"dense": {"size": 65537, "distance": "Cosine"}},
)
assert response.status_code == 422, response.text
assert "size" in response.text
def test_delete_recreate_indexed_vector_scroll():
"""
Delete and recreate a named vector on indexed segments (HNSW built),
then scroll, upsert new data, and scroll again.
"""
_run_delete_recreate_scroll(wait_for_indexing=True)
def test_delete_recreate_unindexed_vector_scroll():
"""
Delete and recreate a named vector without waiting for indexing,
then scroll, upsert new data, and scroll again.
"""
_run_delete_recreate_scroll(wait_for_indexing=False)
def _run_delete_recreate_scroll(wait_for_indexing: bool):
coll = "test_named_vec_idx" if wait_for_indexing else "test_named_vec_noidx"
dim_a = 128
dim_b = 64
dim_a_new = 96
num_points = 200
# Setup
request_with_validation(
api='/collections/{collection_name}',
method="DELETE",
path_params={'collection_name': coll},
)
response = request_with_validation(
api='/collections/{collection_name}',
method="PUT",
path_params={'collection_name': coll},
body={
"vectors": {
"vec_a": {"size": dim_a, "distance": "Cosine"},
"vec_b": {"size": dim_b, "distance": "Cosine"},
},
"optimizers_config": {
"indexing_threshold": 10 if wait_for_indexing else 10_000,
},
},
)
assert response.ok
# Upsert points
points = [
{
"id": i,
"vector": {
"vec_a": [float(i * x % 97) / 100 for x in range(dim_a)],
"vec_b": [float(i * x % 53) / 100 for x in range(dim_b)],
},
}
for i in range(num_points)
]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': coll},
query_params={'wait': 'true'},
body={"points": points},
)
assert response.ok
if wait_for_indexing:
wait_collection_green(coll)
# Delete vec_a
response = request_with_validation(
api='/collections/{collection_name}/vectors/{vector_name}',
method="DELETE",
path_params={'collection_name': coll, 'vector_name': 'vec_a'},
query_params={'wait': 'true'},
)
assert response.ok
# Recreate vec_a with different dimensions
response = request_with_validation(
api='/collections/{collection_name}/vectors/{vector_name}',
method="PUT",
path_params={'collection_name': coll, 'vector_name': 'vec_a'},
query_params={'wait': 'true'},
body={"dense": {"size": dim_a_new, "distance": "Dot"}},
)
assert response.ok
# First scroll — must not panic
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': coll},
body={"limit": 10, "with_vector": True},
)
assert response.ok
result = response.json()['result']
assert len(result['points']) == 10
for point in result['points']:
assert 'vec_b' in point['vector']
assert len(point['vector']['vec_b']) == dim_b
# Upsert some points with data for the new vec_a
updated_points = [
{
"id": i,
"vector": {
"vec_a": [float(i * x % 41) / 100 for x in range(dim_a_new)],
"vec_b": [float(i * x % 53) / 100 for x in range(dim_b)],
},
}
for i in range(50)
]
response = request_with_validation(
api='/collections/{collection_name}/points',
method="PUT",
path_params={'collection_name': coll},
query_params={'wait': 'true'},
body={"points": updated_points},
)
assert response.ok
# Second scroll — verify updated points have vec_a data
response = request_with_validation(
api='/collections/{collection_name}/points/scroll',
method="POST",
path_params={'collection_name': coll},
body={"limit": num_points, "with_vector": True},
)
assert response.ok
result = response.json()['result']
assert len(result['points']) == num_points
updated_ids = set(range(50))
for point in result['points']:
assert 'vec_b' in point['vector']
assert len(point['vector']['vec_b']) == dim_b
if point['id'] in updated_ids:
assert 'vec_a' in point['vector'], f"vec_a missing from updated point {point['id']}"
assert len(point['vector']['vec_a']) == dim_a_new
# Cleanup
request_with_validation(
api='/collections/{collection_name}',
method="DELETE",
path_params={'collection_name': coll},
)
def wait_collection_green(collection_name, timeout=30):
"""Poll collection status until optimizer is idle."""
start = time.time()
while time.time() - start < timeout:
r = request_with_validation(
api='/collections/{collection_name}',
method="GET",
path_params={'collection_name': collection_name},
)
assert r.ok
if r.json()['result']['status'] == 'green':
return
time.sleep(0.5)
raise TimeoutError(f"Collection {collection_name} did not turn green within {timeout}s")