Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ci/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ services:
OBJECTS_TTL_DELETE_SCHEDULE: "@every 12h" # for objectTTL tests to work
EXPORT_ENABLED: 'true'
EXPORT_DEFAULT_PATH: "/var/lib/weaviate/exports"
# for config.delete_vector_index tests to work. The endpoint is experimental and off by
# default; remove this once it is promoted to a supported release. Ignored by servers that
# do not know the flag.
ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT: 'true'

contextionary:
environment:
Expand Down
63 changes: 62 additions & 1 deletion integration/test_collection_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
from typing import Generator, List, Optional, Union
import time
from typing import Any, Dict, Generator, List, Optional, Union

import pytest as pytest
from _pytest.fixtures import SubRequest
Expand All @@ -11,6 +12,7 @@
OpenAICollection,
_sanitize_collection_name,
)
from weaviate.collections import Collection
from weaviate.collections.classes.config import (
_BQConfig,
_CollectionConfig,
Expand All @@ -21,6 +23,7 @@
_VectorIndexConfigDynamic,
_VectorIndexConfigFlat,
_VectorIndexConfigHNSW,
_VectorIndexConfigNone,
_VectorIndexConfigHNSWUpdate,
Configure,
Reconfigure,
Expand All @@ -37,6 +40,7 @@
Rerankers,
_RerankerProvider,
Tokenization,
_NamedVectorConfig,
_NamedVectorConfigCreate,
_VectorizerConfigCreate,
IndexName,
Expand Down Expand Up @@ -2694,3 +2698,60 @@ def test_text_analyzer_roundtrip_from_dict(
assert config == new
assert config.to_dict() == new.to_dict()
client.collections.delete(name)


def _vector_config_without_index(
collection: Collection[Any, Any], vector_name: str, timeout: float = 30
) -> Dict[str, _NamedVectorConfig]:
"""Poll the collection config until `vector_name` no longer has an index.

The drop is applied asynchronously, a 200 from the endpoint only means that Weaviate accepted
the request. It then becomes visible in two steps: the vector first stays in the schema with a
`vector_index_config` of `_VectorIndexConfigNone`, and once the index is gone from disk the
entry is removed from the schema altogether. Both shapes must parse, so accept either.
"""
start = time.time()
while True:
vector_config = collection.config.get().vector_config
assert vector_config is not None
if vector_name not in vector_config or isinstance(
vector_config[vector_name].vector_index_config, _VectorIndexConfigNone
):
return vector_config
if time.time() - start > timeout:
pytest.fail(f"vector index of {vector_name} was not dropped within {timeout}s")
time.sleep(0.2)


def test_delete_vector_index(collection_factory: CollectionFactory) -> None:
"""Test that dropping the index of a named vector leaves the rest of the collection usable."""
collection_dummy = collection_factory("dummy")
if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0):
pytest.skip("delete vector index not supported before 1.39.0")

collection = collection_factory(
properties=[Property(name="name", data_type=DataType.TEXT)],
vector_config=[
Configure.Vectors.self_provided(name="dropped"),
Configure.Vectors.self_provided(name="kept"),
],
)
collection.data.insert(
properties={"name": "banana"},
vector={"dropped": [1, 2], "kept": [3, 4]},
)

config = collection.config.get()
assert config.vector_config is not None
assert not isinstance(
config.vector_config["dropped"].vector_index_config, _VectorIndexConfigNone
)

assert collection.config.delete_vector_index("dropped") is None

vector_config = _vector_config_without_index(collection, "dropped")
# vectors that were not dropped keep their index
assert not isinstance(vector_config["kept"].vector_index_config, _VectorIndexConfigNone)

# searching the vector that still has an index keeps working
assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1
82 changes: 81 additions & 1 deletion mock_tests/test_collection.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import datetime
from typing import Any, Dict, Literal
import json
from typing import Any, Dict, List, Literal

import grpc
import pytest
from pytest_httpserver import HTTPServer
from werkzeug import Request, Response

import weaviate
import weaviate.classes as wvc
Expand Down Expand Up @@ -504,6 +506,84 @@ async def test_async_collection_exists(weaviate_mock: HTTPServer) -> None:
assert e.value.status_code == 500


def test_delete_vector_index(weaviate_mock: HTTPServer) -> None:
# the collection name is capitalized by the client before it hits the path
weaviate_mock.expect_request(
"/v1/schema/Test/vectors/vec/index", method="DELETE"
).respond_with_json(response_json={}, status=200)
weaviate_mock.expect_request(
"/v1/schema/Test/vectors/missing/index", method="DELETE"
).respond_with_json(
response_json={"error": [{"message": "vector index missing not found"}]}, status=422
)
weaviate_mock.expect_request(
"/v1/schema/Test/vectors/disabled/index", method="DELETE"
).respond_with_json(
response_json={
"error": [
{
"message": "alter schema drop vector index endpoint is experimental and disabled by default"
}
]
},
status=500,
)

with weaviate.connect_to_local(
port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True
) as client:
assert client.collections.use("test").config.delete_vector_index("vec") is None

# a non-OK answer (e.g. unknown vector name) surfaces as UnexpectedStatusCodeError
with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as e:
client.collections.use("test").config.delete_vector_index("missing")
assert e.value.status_code == 422

# a disabled experimental endpoint answers 500; the server message must reach the
# exception rather than being masked as a missing vector
with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as disabled:
client.collections.use("test").config.delete_vector_index("disabled")
assert disabled.value.status_code == 500
assert "experimental and disabled by default" in disabled.value.message

with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError):
client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type]


def test_create_from_dict_skips_dropped_vectors(weaviate_mock: HTTPServer) -> None:
"""Entries with vectorIndexType "none" cannot be re-created and are stripped before the POST."""
bodies: List[Dict[str, Any]] = []

def handler(request: Request) -> Response:
body = request.get_json()
bodies.append(body)
return Response(json.dumps({"class": body["class"]}), content_type="application/json")

weaviate_mock.expect_request("/v1/schema", method="POST").respond_with_handler(handler)

hnsw_entry = {"vectorizer": {"none": {}}, "vectorIndexType": "hnsw", "vectorIndexConfig": {}}
dropped_entry = {"vectorizer": {"none": {}}, "vectorIndexType": "none"}

with weaviate.connect_to_local(
port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True
) as client:
with pytest.warns(UserWarning, match=r"Col001.*dropped"):
client.collections.create_from_dict(
{
"class": "TestDropped",
"vectorConfig": {"dropped": dropped_entry, "kept": hnsw_entry},
}
)
assert bodies[-1]["vectorConfig"] == {"kept": hnsw_entry}

# once every vector is stripped the empty block is omitted, not sent as {}
with pytest.warns(UserWarning, match=r"Col001.*only"):
client.collections.create_from_dict(
{"class": "TestAllDropped", "vectorConfig": {"only": dropped_entry}}
)
assert "vectorConfig" not in bodies[-1]


def test_grpc_client_version_header(
metadata_capture_collection: tuple[
weaviate.collections.Collection, MockMetadataCaptureWeaviateService
Expand Down
152 changes: 152 additions & 0 deletions test/collection/test_config_methods.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,162 @@
from typing import Any, Dict

import pytest

from weaviate.collections.classes.config import VectorIndexType, _VectorIndexConfigNone
from weaviate.exceptions import SchemaValidationError
from weaviate.collections.classes.config_methods import (
_collection_config_from_json,
_collection_config_simple_from_json,
_collection_configs_simple_from_json,
_nested_properties_from_config,
_properties_from_config,
)

HNSW_CONFIG = {
"skip": False,
"cleanupIntervalSeconds": 300,
"maxConnections": 64,
"efConstruction": 128,
"ef": -1,
"dynamicEfMin": 100,
"dynamicEfMax": 500,
"dynamicEfFactor": 8,
"vectorCacheMaxObjects": 1000000000000,
"flatSearchCutoff": 40000,
"distance": "cosine",
}


def _schema_with_vector_config(vector_config: Dict[str, Any]) -> Dict[str, Any]:
"""Build a minimal collection schema, as returned by Weaviate, around the given vectorConfig."""
return {
"class": "TestCollection",
"vectorConfig": vector_config,
"properties": [],
"invertedIndexConfig": {
"bm25": {"b": 0.75, "k1": 1.2},
"cleanupIntervalSeconds": 60,
"stopwords": {"preset": "en", "additions": None, "removals": None},
},
"multiTenancyConfig": {"enabled": False},
"replicationConfig": {"factor": 1, "deletionStrategy": "NoAutomatedResolution"},
"shardingConfig": {
"virtualPerPhysical": 128,
"desiredCount": 1,
"actualCount": 1,
"desiredVirtualCount": 128,
"actualVirtualCount": 128,
"key": "_id",
"strategy": "hash",
"function": "murmur3",
},
}


def test_collection_config_from_json_with_dropped_vector_index() -> None:
"""A vector whose index was dropped is returned without a vectorIndexConfig."""
# Shape returned by Weaviate after `collection.config.delete_vector_index("dropped")`:
# the entry stays in the schema, `vectorIndexType` becomes "none" and `vectorIndexConfig`
# is omitted entirely.
schema = _schema_with_vector_config(
{
"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"},
"kept": {
"vectorizer": {"none": {}},
"vectorIndexType": "hnsw",
"vectorIndexConfig": HNSW_CONFIG,
},
}
)

config = _collection_config_from_json(schema)

assert config.vector_config is not None
assert isinstance(config.vector_config["dropped"].vector_index_config, _VectorIndexConfigNone)
assert config.vector_config["dropped"].vector_index_config.vector_index_type() == "none"
assert not isinstance(config.vector_config["kept"].vector_index_config, _VectorIndexConfigNone)

# The dropped vector must round-trip back to the "none" index type the server reported.
as_dict = config.to_dict()
assert as_dict["vectorConfig"]["dropped"]["vectorIndexType"] == VectorIndexType.NONE.value
assert "vectorIndexConfig" not in as_dict["vectorConfig"]["dropped"]
assert as_dict["vectorConfig"]["kept"]["vectorIndexType"] == VectorIndexType.HNSW.value


def test_collection_config_from_json_missing_vector_index_config_raises() -> None:
"""A non-dropped vector missing its vectorIndexConfig must fail fast, not parse as None."""
schema = _schema_with_vector_config(
{"broken": {"vectorizer": {"none": {}}, "vectorIndexType": "hnsw"}}
)

with pytest.raises(SchemaValidationError, match="broken"):
_collection_config_from_json(schema)


def test_collection_config_from_json_unknown_vector_index_type_raises() -> None:
"""An index type the client does not know is reported as such, not as a missing config."""
# `vectorIndexConfig` is present and populated; only the type is unknown to this client.
schema = _schema_with_vector_config(
{
"future": {
"vectorizer": {"none": {}},
"vectorIndexType": "spann",
"vectorIndexConfig": {"distance": "cosine", "searchListSize": 100},
}
}
)

with pytest.raises(SchemaValidationError, match="unknown vectorIndexType"):
_collection_config_from_json(schema)


def _schema_without_any_vector() -> Dict[str, Any]:
"""Schema of a named-vector collection whose vectors were all dropped.

Once the drops finalize the server removes every `vectorConfig` entry, so the block is
omitted, and a named-vector collection never has a top-level `vectorizer`, `vectorIndexType`
or `vectorIndexConfig`. (A legacy single-vector collection cannot reach this shape: the server
rejects dropping its index, so it always keeps a top-level `vectorizer`.)
"""
return {
"class": "TestCollection",
"properties": [],
"invertedIndexConfig": {
"bm25": {"b": 0.75, "k1": 1.2},
"cleanupIntervalSeconds": 60,
"stopwords": {"preset": "en", "additions": None, "removals": None},
},
"multiTenancyConfig": {"enabled": False},
"replicationConfig": {"factor": 1, "deletionStrategy": "NoAutomatedResolution"},
"shardingConfig": {
"virtualPerPhysical": 128,
"desiredCount": 1,
"actualCount": 1,
"desiredVirtualCount": 128,
"actualVirtualCount": 128,
"key": "_id",
"strategy": "hash",
"function": "murmur3",
},
}


def test_collection_config_from_json_all_vectors_dropped() -> None:
"""A collection whose vectors were all dropped has no top-level vectorizer."""
config = _collection_config_from_json(_schema_without_any_vector())

assert config.vectorizer is None
assert config.vector_index_type is None
assert config.vector_config is None


def test_collection_config_simple_from_json_all_vectors_dropped() -> None:
"""`collections.list_all()` must not choke on a collection whose vectors were all dropped."""
config = _collection_config_simple_from_json(_schema_without_any_vector())

assert config.vectorizer is None
assert config.vector_config is None


def test_collection_config_simple_from_json_with_none_vectorizer_config() -> None:
"""Test that _collection_configs_simple_from_json handles None vectorizer config."""
Expand Down
Loading
Loading