import pytest import time import requests from .helpers.helpers import qdrant_host_headers, 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'}, headers=qdrant_host_headers(), 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'}, headers=qdrant_host_headers(), 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")