diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index 786b486cd..b6591f786 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -7,6 +7,7 @@ import weaviate import weaviate.classes as wvc from integration.conftest import ( + AsyncCollectionFactory, CollectionFactory, OpenAICollection, _sanitize_collection_name, @@ -40,9 +41,14 @@ _NamedVectorConfigCreate, _VectorizerConfigCreate, IndexName, + BM25Algorithm, + InvertedIndexState, + InvertedIndexTaskStatus, + InvertedIndexType, ) from weaviate.collections.classes.tenants import Tenant from weaviate.exceptions import ( + ReindexCanceledError, UnexpectedStatusCodeError, WeaviateInvalidInputError, WeaviateUnsupportedFeatureError, @@ -2694,3 +2700,297 @@ def test_text_analyzer_roundtrip_from_dict( assert config == new assert config.to_dict() == new.to_dict() client.collections.delete(name) + + +def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFactory) -> None: + """Test the full runtime lifecycle of a searchable index: create, no-op, rebuild, cancel, delete.""" + collection_dummy = collection_factory("dummy") + if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("Runtime property reindex requires Weaviate >= 1.39.0") + + collection = collection_factory( + properties=[ + Property( + name="name", + data_type=DataType.TEXT, + index_filterable=True, + index_searchable=False, + ) + ], + ) + collection.data.insert_many([{"name": f"object {i}"} for i in range(10)]) + + # create the searchable index declaratively and wait for it to become ready + status = collection.config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.WORD, + wait_for_completion=True, + ) + assert status.type == "searchable" + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.WORD + + # re-putting the matching configuration is a no-op + task = collection.config.update_property_index( + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.WORD + ) + assert task.status == InvertedIndexTaskStatus.NO_OP + assert task.task_id is None + + # the status endpoint reports the index as ready + indexes = collection.config.get_property_indexes() + assert indexes.collection == collection.name + entry = next( + index + for prop in indexes.properties + if prop.name == "name" + for index in prop.indexes + if index.type == "searchable" + ) + assert entry.state == InvertedIndexState.READY + # a searchable index reports its BM25 scoring algorithm, parsed into the enum + assert entry.algorithm in (BM25Algorithm.WAND, BM25Algorithm.BLOCKMAX) + + # rebuild the index from scratch + status = collection.config.rebuild_property_index( + "name", InvertedIndexType.SEARCHABLE, wait_for_completion=True + ) + assert status.type == "searchable" + assert status.state == InvertedIndexState.READY + + # cancelling when no task is live is an idempotent no-op + task = collection.config.cancel_property_index_task("name", InvertedIndexType.SEARCHABLE) + assert task.status == InvertedIndexTaskStatus.NO_OP + + # the pre-existing delete API removes the index again + assert collection.config.delete_property_index("name", "searchable") is True + + +def test_property_reindex_range_filters(collection_factory: CollectionFactory) -> None: + """Test creating a rangeFilters index on an int property via an empty request body.""" + collection_dummy = collection_factory("dummy") + if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("Runtime property reindex requires Weaviate >= 1.39.0") + + collection = collection_factory( + properties=[ + Property( + name="age", + data_type=DataType.INT, + index_filterable=True, + index_range_filters=False, + ) + ], + ) + collection.data.insert_many([{"age": i} for i in range(10)]) + + status = collection.config.update_property_index( + "age", InvertedIndexType.RANGE_FILTERS, wait_for_completion=True + ) + assert status.type == "rangeFilters" + assert status.state == InvertedIndexState.READY + + entry = next( + index + for prop in collection.config.get_property_indexes().properties + if prop.name == "age" + for index in prop.indexes + if index.type == "rangeFilters" + ) + assert entry.state == InvertedIndexState.READY + + +def test_property_reindex_coupled_tokenization_change( + collection_factory: CollectionFactory, +) -> None: + """Test that a tokenization change on searchable is coupled with the filterable index as one task.""" + collection_dummy = collection_factory("dummy") + if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("Runtime property reindex requires Weaviate >= 1.39.0") + + collection = collection_factory( + properties=[ + Property( + name="name", + data_type=DataType.TEXT, + index_filterable=True, + index_searchable=True, + tokenization=Tokenization.WORD, + ) + ], + ) + collection.data.insert_many([{"name": f"object {i}"} for i in range(100)]) + + task = collection.config.update_property_index( + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.FIELD + ) + assert task.status == InvertedIndexTaskStatus.STARTED + assert task.task_id is not None + + # join contract: an identical re-PUT while the task is in flight returns the EXISTING task + join = collection.config.update_property_index( + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.FIELD + ) + if join.status == InvertedIndexTaskStatus.STARTED: + assert join.task_id == task.task_id + else: + # the task already finalized; a re-PUT of matching configuration is a no-op + assert join.status == InvertedIndexTaskStatus.NO_OP + + prop = next(p for p in collection.config.get_property_indexes().properties if p.name == "name") + searchable = next(i for i in prop.indexes if i.type == "searchable") + filterable = next(i for i in prop.indexes if i.type == "filterable") + if searchable.task_id is not None: + # both entries are driven by the one coupled task while it is in flight + assert searchable.task_id == task.task_id + assert filterable.task_id == task.task_id + assert searchable.target_tokenization == Tokenization.FIELD + assert filterable.target_tokenization == Tokenization.FIELD + else: + # the task already finalized before the first poll + assert searchable.tokenization == Tokenization.FIELD + + # poll the status endpoint (joining the in-flight task via the wait path) until done + status = collection.config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.FIELD, + wait_for_completion=True, + ) + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + + prop = next(p for p in collection.config.get_property_indexes().properties if p.name == "name") + filterable = next(i for i in prop.indexes if i.type == "filterable") + assert filterable.state == InvertedIndexState.READY + assert filterable.tokenization == Tokenization.FIELD + + +def test_property_reindex_cancel_in_flight(collection_factory: CollectionFactory) -> None: + """Cancel a live reindex task and observe an actual CANCELLED result + wait-path raise.""" + collection_dummy = collection_factory("dummy") + if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("Runtime property reindex requires Weaviate >= 1.39.0") + + collection = collection_factory( + properties=[ + Property( + name="name", + data_type=DataType.TEXT, + index_filterable=True, + index_searchable=True, + tokenization=Tokenization.WORD, + ) + ], + ) + # a large batch keeps the coupled reindex task live long enough to cancel it + collection.data.insert_many([{"name": f"object {i}"} for i in range(1000)]) + + task = collection.config.update_property_index( + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.FIELD + ) + assert task.status == InvertedIndexTaskStatus.STARTED + assert task.task_id is not None + + cancel = collection.config.cancel_property_index_task("name", InvertedIndexType.SEARCHABLE) + if cancel.status == InvertedIndexTaskStatus.CANCELLED: + # a live task was stopped: the cancellation targets the same coupled task we submitted + assert cancel.task_id == task.task_id + else: + # the task finished before we could cancel it — nothing left to stop + assert cancel.status == InvertedIndexTaskStatus.NO_OP + + # the index settles into a terminal state; if cancelled, waiting on it raises + if cancel.status == InvertedIndexTaskStatus.CANCELLED: + try: + status = collection.config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.FIELD, + wait_for_completion=True, + ) + # a resubmit may relaunch and complete instead of surfacing the cancelled entry + assert status.state == InvertedIndexState.READY + except ReindexCanceledError: + pass + + +def test_property_reindex_multi_tenant(collection_factory: CollectionFactory) -> None: + """Test rangeFilters creation and rebuild with a tenants selection on a multi-tenant collection.""" + collection_dummy = collection_factory("dummy") + if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("Runtime property reindex requires Weaviate >= 1.39.0") + + collection = collection_factory( + properties=[ + Property( + name="age", + data_type=DataType.INT, + index_filterable=True, + index_range_filters=False, + ) + ], + multi_tenancy_config=Configure.multi_tenancy(enabled=True), + ) + collection.tenants.create([Tenant(name="tenant1"), Tenant(name="tenant2")]) + collection.with_tenant("tenant1").data.insert_many([{"age": i} for i in range(5)]) + + status = collection.config.update_property_index( + "age", + InvertedIndexType.RANGE_FILTERS, + tenants=["tenant1", "tenant2"], + wait_for_completion=True, + ) + assert status.type == "rangeFilters" + assert status.state == InvertedIndexState.READY + + status = collection.config.rebuild_property_index( + "age", InvertedIndexType.RANGE_FILTERS, tenants=["tenant1"], wait_for_completion=True + ) + assert status.type == "rangeFilters" + assert status.state == InvertedIndexState.READY + + +@pytest.mark.asyncio +async def test_property_reindex_async(async_collection_factory: AsyncCollectionFactory) -> None: + """Test the runtime property reindex lifecycle through the async client.""" + collection = await async_collection_factory( + properties=[ + Property( + name="name", + data_type=DataType.TEXT, + index_filterable=True, + index_searchable=False, + ) + ], + ) + if collection._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("Runtime property reindex requires Weaviate >= 1.39.0") + + await collection.data.insert_many([{"name": f"object {i}"} for i in range(10)]) + + status = await collection.config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.WORD, + wait_for_completion=True, + ) + assert status.type == "searchable" + assert status.state == InvertedIndexState.READY + + task = await collection.config.update_property_index( + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.WORD + ) + assert task.status == InvertedIndexTaskStatus.NO_OP + + indexes = await collection.config.get_property_indexes() + assert indexes.collection == collection.name + + status = await collection.config.rebuild_property_index( + "name", InvertedIndexType.SEARCHABLE, wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + + task = await collection.config.cancel_property_index_task("name", InvertedIndexType.SEARCHABLE) + assert task.status == InvertedIndexTaskStatus.NO_OP diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py new file mode 100644 index 000000000..cf653f9a6 --- /dev/null +++ b/mock_tests/test_property_reindex.py @@ -0,0 +1,1195 @@ +import json +import warnings +from typing import Generator, Union + +import grpc +import pytest +from pytest_httpserver import HTTPServer +from werkzeug.wrappers import Response + +import weaviate +from mock_tests.conftest import MOCK_IP, MOCK_PORT, MOCK_PORT_GRPC +from weaviate.collections.config import executor as reindex_executor +from weaviate.collections.classes.config import ( + BM25Algorithm, + DataType, + InvertedIndexState, + InvertedIndexTaskStatus, + InvertedIndexType, + Tokenization, +) +from weaviate.exceptions import ( + ReindexCanceledError, + ReindexFailedError, + ReindexTimeoutError, + WeaviateUnsupportedFeatureError, +) + +COLLECTION = "TestCollection" +SCHEMA_PATH = f"/v1/schema/{COLLECTION}" +INDEXES_PATH = f"{SCHEMA_PATH}/indexes" +TASK_ID = "TestCollection:change-tokenization:name:ab3f" + + +def _indexes(index_name: str = "searchable", **fields: object) -> dict: + """A realistic GET /schema/{class}/indexes payload wrapping a single index entry. + + Pass wire fields, e.g. status="ready"/"indexing"/"failed", tokenization, targetTokenization, + algorithm, targetAlgorithm, progress, taskId. A plain ready entry carries NO taskId. + """ + entry: dict = {"type": index_name, **fields} + return { + "collection": COLLECTION, + "properties": [{"name": "name", "dataType": "text", "indexes": [entry]}], + } + + +def _no_index() -> dict: + """An /indexes payload where the target property/index entry is absent (vanished / missing).""" + return {"collection": COLLECTION, "properties": []} + + +@pytest.fixture(scope="function") +def fast_poll(monkeypatch: pytest.MonkeyPatch) -> None: + """Shrink the reindex poll interval so stall/grace tests run against the REAL thresholds fast.""" + monkeypatch.setattr(reindex_executor, "_REINDEX_POLL_INTERVAL_SECONDS", 0.005) + + +@pytest.fixture(scope="function") +def weaviate_139_mock(ready_mock: HTTPServer) -> Generator[HTTPServer, None, None]: + """A mock server advertising Weaviate 1.39.0, which supports runtime property reindexing.""" + ready_mock.expect_request("/v1/meta").respond_with_json({"version": "1.39.0"}) + ready_mock.expect_request("/v1/nodes").respond_with_json( + {"nodes": [{"gitHash": "ABC", "status": "HEALTHY"}]} + ) + ready_mock.expect_request("/v1/.well-known/openid-configuration").respond_with_response( + Response(json.dumps({}), status=404) + ) + yield ready_mock + + +@pytest.fixture(scope="function") +def client_139( + weaviate_139_mock: HTTPServer, start_grpc_server: grpc.Server +) -> Generator[weaviate.WeaviateClient, None, None]: + client = weaviate.connect_to_local(port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC) + yield client + client.close() + + +def test_update_property_index_started( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A single tokenization change is a valid PUT body (the server allows at most one change).""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "name", + "searchable", + tokenization=Tokenization.WORD, + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_update_property_index_algorithm_only( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """An algorithm-only change is a valid single-change PUT; the enum serializes to its wire value.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"algorithm": "blockmax"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "name", + "searchable", + algorithm=BM25Algorithm.BLOCKMAX, + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_update_property_index_no_op( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"status": "NO_OP"}, status=200) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD + ) + assert task.task_id is None + assert task.status == InvertedIndexTaskStatus.NO_OP + weaviate_139_mock.check_assertions() + + +def test_update_property_index_range_filters_with_tenants( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A rangeFilters creation sends an empty body and encodes tenants as a csv query param.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/age/index/rangeFilters", + method="PUT", + query_string={"tenants": "tenant1,tenant2"}, + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "age", "rangeFilters", tenants=["tenant1", "tenant2"] + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_tokenization_change( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A tokenization change converges when a ready entry reports the NEW tokenization. + + The in-flight entry (indexing, target_tokenization=new) must not be treated as done; only the + ready entry carrying the new tokenization returns. + """ + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: migration in flight (still old tokenization, target set, carries our taskId) + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes( + status="indexing", + progress=0.5, + taskId=TASK_ID, + tokenization="word", + targetTokenization="field", + ) + ) + # poll 2: ready with the new tokenization (a plain ready entry carries no taskId) + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="field") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.type == "searchable" + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + assert status.task_id is None # a ready entry carries no task id + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_stale_pre_flip_not_accepted( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A ready entry with the OLD tokenization is the stale pre-flip state and must not return. + + Regression pin for finding #1: the wait keeps polling until the ready entry reports the + requested (new) tokenization. If a stale-old ready were accepted, the assertion below fails. + """ + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: a ready entry still on the OLD tokenization (pre-flip) - must NOT be accepted + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="word") + ) + # poll 2: ready on the NEW tokenization + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="field") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.tokenization == Tokenization.FIELD + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_finalize_window_not_done( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """Regression pin for finding #1: indexing@progress=1.0 is NOT done, only ready is. + + The finalize window shows indexing at progress 1.0 with the target still set; the wait must + keep polling and return only on the subsequent ready poll. + """ + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: finalize window - indexing at progress 1.0 with the target still set + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes( + status="indexing", + progress=1.0, + taskId=TASK_ID, + tokenization="word", + targetTokenization="field", + ) + ) + # poll 2: flipped to ready on the new tokenization + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="field") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_algorithm_change( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """An algorithm change (wand -> blockmax) converges on a ready entry reporting blockmax.""" + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"algorithm": "blockmax"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: in-flight, still wand with the target set + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="indexing", taskId=TASK_ID, algorithm="wand", targetAlgorithm="blockmax") + ) + # poll 2: ready on blockmax + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", algorithm="blockmax") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", algorithm=BM25Algorithm.BLOCKMAX, wait_for_completion=True + ) + assert status.algorithm is BM25Algorithm.BLOCKMAX + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_create_range_filters( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A create with an empty body converges as soon as the index exists and is ready.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/age/index/rangeFilters", + method="PUT", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + { + "collection": COLLECTION, + "properties": [ + { + "name": "age", + "dataType": "int", + "indexes": [{"type": "rangeFilters", "status": "ready"}], + } + ], + } + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "age", "rangeFilters", wait_for_completion=True + ) + assert status.type == "rangeFilters" + assert status.state == InvertedIndexState.READY + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_no_op( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A NO_OP submit returns the current status via a single get_property_indexes fetch.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"status": "NO_OP"}, status=200) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="word") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.WORD + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_timeout( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """An index that never reaches ready past the timeout raises ReindexTimeoutError.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # /indexes always reports the migration still in flight + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes( + status="indexing", + progress=0.3, + taskId=TASK_ID, + tokenization="word", + targetTokenization="field", + ) + ) + + with pytest.raises(ReindexTimeoutError): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", + "searchable", + tokenization=Tokenization.FIELD, + wait_for_completion=True, + timeout=0.5, + ) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_stall_vanished_after_active( + fast_poll: None, weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A task seen active then vanishing from /indexes is bounded (timeout=None must not hang).""" + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: our task is active (resets the stall counter) ... + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="indexing", taskId=TASK_ID, tokenization="word", targetTokenization="field") + ) + # ... then the entry vanishes for good (server fault) - bounded by the no-progress guard + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json(_no_index()) + + with pytest.raises(ReindexTimeoutError, match="did not progress"): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_stall_never_appears( + fast_poll: None, weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """An entry absent from the very first poll (never appears) is bounded, not an infinite wait.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json(_no_index()) + + with pytest.raises(ReindexTimeoutError, match="did not progress"): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_stall_ready_on_old_config( + fast_poll: None, weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A ready entry stuck on the OLD tokenization forever is bounded by the no-progress guard.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # ready, but never flips to the requested tokenization (server never completed the swap) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="word") + ) + + with pytest.raises(ReindexTimeoutError, match="did not progress"): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_healthy_long_not_cut_off( + fast_poll: None, weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A legitimately long reindex (indexing for well past the stall bound) must NOT be cut off. + + Proves the no-progress guard never trips a healthy migration: each indexing poll (including the + finalize window at progress 1.0) resets the stall counter, so it completes normally on ready. + """ + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + # stay INDEXING for well beyond the stall threshold, then flip to ready on the new tokenization + indexing_polls = reindex_executor._REINDEX_STALL_POLLS + 5 + state = {"n": 0} + + def handler(request: object) -> Response: + state["n"] += 1 + if state["n"] <= indexing_polls: + # include the finalize window (progress 1.0) on the last indexing poll + progress = 1.0 if state["n"] == indexing_polls else 0.5 + body = _indexes( + status="indexing", + progress=progress, + taskId=TASK_ID, + tokenization="word", + targetTokenization="field", + ) + else: + body = _indexes(status="ready", tokenization="field") + return Response(json.dumps(body), content_type="application/json") + + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_handler(handler) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + assert state["n"] > reindex_executor._REINDEX_STALL_POLLS # actually polled past the bound + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_no_op_missing_entry( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A NO_OP whose index is missing from /indexes raises a clear error, not a bare assert.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"status": "NO_OP"}, status=200) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json(_no_index()) + + with pytest.raises(ReindexFailedError, match="NO_OP"): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_enum_vs_str_convergence( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """Convergence compares by wire value, so the parsed Tokenization enum matches the wire string.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # entry.tokenization parses to Tokenization.FIELD (enum); expected is the wire string "field" + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="field") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.tokenization is Tokenization.FIELD + weaviate_139_mock.check_assertions() + + +def test_update_property_index_tolerant_task_status( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """Unknown task statuses pass through as raw strings (the spec declares the field open-vocabulary).""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"taskId": TASK_ID, "status": "SOMETHING_NEW"}, status=202) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.WORD + ) + assert task.task_id == TASK_ID + assert task.status == "SOMETHING_NEW" + assert not isinstance(task.status, InvertedIndexTaskStatus) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_bare_str_tenant( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A bare string tenant is normalized to a single csv value, not exploded into characters.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/age/index/rangeFilters", + method="PUT", + query_string={"tenants": "tenant1"}, + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "age", "rangeFilters", tenants="tenant1" + ) + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_raises_failed( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A failed entry belonging to our task raises ReindexFailedError naming the task id.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="failed", taskId=TASK_ID, tokenization="word") + ) + + with pytest.raises(ReindexFailedError) as e: + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + assert TASK_ID in str(e.value) + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_stale_failed_not_raised( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A stale failed entry with a DIFFERENT task id must not raise; the wait keeps polling. + + Regression pin: only a failed entry that belongs to our submitted task is terminal. + """ + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: a stale failed entry from a PRIOR reindex (different task id) - must NOT raise + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="failed", taskId="stale-other-task:x:y:0000", tokenization="word") + ) + # poll 2: our migration completes to ready on the new tokenization + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="field") + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_raises_cancelled( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A cancelled entry belonging to our task raises ReindexCanceledError.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="cancelled", taskId=TASK_ID, tokenization="word") + ) + + with pytest.raises(ReindexCanceledError): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + weaviate_139_mock.check_assertions() + + +def test_rebuild_property_index_wait( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A rebuild wait returns once the entry (seen active) reaches ready (best-effort).""" + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/properties/name/index/searchable/rebuild", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # poll 1: rebuild in flight, carrying our task id + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="indexing", progress=0.5, taskId=TASK_ID, tokenization="word") + ) + # poll 2: ready (task seen active -> best-effort completion) + weaviate_139_mock.expect_ordered_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="word") + ) + + status = client_139.collections.use(COLLECTION).config.rebuild_property_index( + "name", "searchable", wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + weaviate_139_mock.check_assertions() + + +def test_rebuild_property_index_wait_fast_ready_grace( + fast_poll: None, weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A fast rebuild whose task is never observed active returns after the (wide) ready grace.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable/rebuild", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # the index is ready from the first poll and stays ready (no active task ever observed) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="word") + ) + + status = client_139.collections.use(COLLECTION).config.rebuild_property_index( + "name", "searchable", wait_for_completion=True + ) + assert status.state == InvertedIndexState.READY + weaviate_139_mock.check_assertions() + + +def test_rebuild_property_index( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable/rebuild", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.rebuild_property_index( + "name", "searchable" + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_rebuild_property_index_with_tenants( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/age/index/rangeFilters/rebuild", + method="POST", + query_string={"tenants": "tenant1,tenant2"}, + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.rebuild_property_index( + "age", "rangeFilters", tenants=["tenant1", "tenant2"] + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_cancel_property_index_task_cancelled( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable/cancel", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "CANCELLED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.cancel_property_index_task( + "name", "searchable" + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.CANCELLED + weaviate_139_mock.check_assertions() + + +def test_cancel_property_index_task_no_op( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable/cancel", + method="POST", + json={}, + ).respond_with_json({"status": "NO_OP"}, status=202) + + task = client_139.collections.use(COLLECTION).config.cancel_property_index_task( + "name", "searchable" + ) + assert task.task_id is None + assert task.status == InvertedIndexTaskStatus.NO_OP + weaviate_139_mock.check_assertions() + + +def test_get_property_indexes( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A coupled tokenization change renders both entries with a shared taskId and progress.""" + weaviate_139_mock.expect_request(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( + { + "collection": COLLECTION, + "properties": [ + { + "name": "name", + "dataType": "text", + "description": "a text property", + "indexes": [ + { + "type": "searchable", + "status": "indexing", + "progress": 0.5, + "taskId": TASK_ID, + "tokenization": "word", + "targetTokenization": "field", + "algorithm": "wand", + "targetAlgorithm": "blockmax", + }, + { + "type": "filterable", + "status": "indexing", + "progress": 0.5, + "taskId": TASK_ID, + "tokenization": "word", + "targetTokenization": "field", + }, + ], + }, + { + "name": "age", + "dataType": "int", + "indexes": [{"type": "rangeFilters", "status": "ready"}], + }, + ], + } + ) + + indexes = client_139.collections.use(COLLECTION).config.get_property_indexes() + assert indexes.collection == COLLECTION + assert len(indexes.properties) == 2 + + name = indexes.properties[0] + assert name.name == "name" + assert name.data_type == "text" + assert name.description == "a text property" + assert len(name.indexes) == 2 + searchable, filterable = name.indexes + # `type` parses strictly into the InvertedIndexType enum; str-enum equality still holds + assert searchable.type is InvertedIndexType.SEARCHABLE + assert searchable.type == "searchable" + assert searchable.state == InvertedIndexState.INDEXING + assert searchable.progress == 0.5 + assert searchable.task_id == TASK_ID + assert searchable.tokenization == Tokenization.WORD + assert searchable.target_tokenization == Tokenization.FIELD + # algorithm/target_algorithm parse into the BM25Algorithm enum; str-enum equality still holds + assert searchable.algorithm is BM25Algorithm.WAND + assert searchable.algorithm == "wand" + assert searchable.target_algorithm is BM25Algorithm.BLOCKMAX + assert searchable.target_algorithm == "blockmax" + assert filterable.type == "filterable" + assert filterable.task_id == TASK_ID # coupled change: one task drives both entries + assert filterable.target_tokenization == Tokenization.FIELD + assert filterable.algorithm is None + assert filterable.target_algorithm is None + + age = indexes.properties[1] + assert age.name == "age" + assert age.data_type == "int" + assert age.description is None + assert len(age.indexes) == 1 + assert age.indexes[0].type == "rangeFilters" + assert age.indexes[0].state == InvertedIndexState.READY + assert age.indexes[0].progress is None + assert age.indexes[0].task_id is None + assert age.indexes[0].tokenization is None + + # the nested dataclasses serialize all the way down to a JSON-compatible dict + out = json.loads(json.dumps(indexes.to_dict())) + assert out["collection"] == COLLECTION + assert out["properties"][0]["indexes"][0]["taskId"] == TASK_ID + assert out["properties"][0]["indexes"][0]["targetTokenization"] == "field" + assert out["properties"][1]["dataType"] == "int" + assert out["properties"][1]["indexes"][0]["state"] == "ready" + + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_name,wire", + [ + (InvertedIndexType.SEARCHABLE, "searchable"), + ("searchable", "searchable"), + (InvertedIndexType.FILTERABLE, "filterable"), + ("filterable", "filterable"), + (InvertedIndexType.RANGE_FILTERS, "rangeFilters"), + ("rangeFilters", "rangeFilters"), + ], +) +def test_update_property_index_enum_and_literal_hit_same_route( + weaviate_139_mock: HTTPServer, + client_139: weaviate.WeaviateClient, + index_name: Union[InvertedIndexType, str], + wire: str, +) -> None: + """The enum and literal forms of index_name hit the exact same wire route.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/{wire}", + method="PUT", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.update_property_index( + "name", + # runtime leniency pin: raw strings must keep hitting the same route + index_name, # type: ignore + ) + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_name,wire", + [ + (InvertedIndexType.SEARCHABLE, "searchable"), + ("searchable", "searchable"), + (InvertedIndexType.FILTERABLE, "filterable"), + ("filterable", "filterable"), + (InvertedIndexType.RANGE_FILTERS, "rangeFilters"), + ("rangeFilters", "rangeFilters"), + ], +) +def test_delete_property_index_enum_and_literal_hit_same_route( + weaviate_139_mock: HTTPServer, + client_139: weaviate.WeaviateClient, + index_name: Union[InvertedIndexType, str], + wire: str, +) -> None: + """The enum and literal forms of index_name hit the exact same wire route.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/{wire}", + method="DELETE", + ).respond_with_json({}, status=200) + + assert ( + client_139.collections.use(COLLECTION).config.delete_property_index( + "name", + # runtime leniency pin: raw strings must keep hitting the same route + index_name, # type: ignore + ) + is True + ) + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_name", + [InvertedIndexType.RANGE_FILTERS, "rangeFilters"], +) +def test_rebuild_property_index_enum_and_literal_hit_same_route( + weaviate_139_mock: HTTPServer, + client_139: weaviate.WeaviateClient, + index_name: Union[InvertedIndexType, str], +) -> None: + """The enum and literal forms of index_name hit the exact same rebuild route.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/age/index/rangeFilters/rebuild", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.rebuild_property_index( + "age", + # runtime leniency pin: raw strings must keep hitting the same route + index_name, # type: ignore + ) + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_name", + [InvertedIndexType.RANGE_FILTERS, "rangeFilters"], +) +def test_cancel_property_index_task_enum_and_literal_hit_same_route( + weaviate_139_mock: HTTPServer, + client_139: weaviate.WeaviateClient, + index_name: Union[InvertedIndexType, str], +) -> None: + """The enum and literal forms of index_name hit the exact same cancel route.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/age/index/rangeFilters/cancel", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "CANCELLED"}, status=202) + + task = client_139.collections.use(COLLECTION).config.cancel_property_index_task( + "age", + # runtime leniency pin: raw strings must keep hitting the same route + index_name, # type: ignore + ) + assert task.status == InvertedIndexTaskStatus.CANCELLED + weaviate_139_mock.check_assertions() + + +def test_get_property_indexes_reference_property( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """Reference properties carry the target collection name as dataType and still parse.""" + weaviate_139_mock.expect_request(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( + { + "collection": COLLECTION, + "properties": [ + { + "name": "title", + "dataType": "text", + "indexes": [{"type": "searchable", "status": "ready", "tokenization": "word"}], + }, + { + "name": "ofArticle", + "dataType": "Article", + "indexes": [{"type": "filterable", "status": "ready"}], + }, + ], + } + ) + + indexes = client_139.collections.use(COLLECTION).config.get_property_indexes() + title, ref = indexes.properties + # primitive values match the DataType str-enum + assert title.data_type == DataType.TEXT + # reference properties carry the qualified target collection name instead + assert ref.name == "ofArticle" + assert ref.data_type == "Article" + assert ref.indexes[0].type == "filterable" + + out = json.loads(json.dumps(indexes.to_dict())) + assert out["properties"][1]["dataType"] == "Article" + + weaviate_139_mock.check_assertions() + + +def test_get_property_indexes_tolerant_algorithm( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """An unknown BM25 algorithm passes through as a raw string without raising.""" + weaviate_139_mock.expect_request(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( + { + "collection": COLLECTION, + "properties": [ + { + "name": "name", + "dataType": "text", + "indexes": [ + { + "type": "searchable", + "status": "indexing", + "algorithm": "wand", + "targetAlgorithm": "future_bm25", + } + ], + } + ], + } + ) + + entry = ( + client_139.collections.use(COLLECTION) + .config.get_property_indexes() + .properties[0] + .indexes[0] + ) + assert entry.algorithm is BM25Algorithm.WAND + assert entry.target_algorithm == "future_bm25" + assert not isinstance(entry.target_algorithm, BM25Algorithm) + weaviate_139_mock.check_assertions() + + +def test_delete_property_index_surfaces_server_message( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A DELETE rejection surfaces the server's cause behind a neutral prefix. + + The 422 mutation guard (in-flight reindex task) carries an actionable server message; + the client prefix must only name the failed operation, not assert a cause. + """ + server_message = "cannot delete index: a reindex task is in progress for property 'name'" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="DELETE", + ).respond_with_json({"error": [{"message": server_message}]}, status=422) + + with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as e: + client_139.collections.use(COLLECTION).config.delete_property_index("name", "searchable") + assert e.value.status_code == 422 + assert "Property index may not have been deleted." in e.value.message + assert server_message in e.value.message + assert "may not exist" not in e.value.message + weaviate_139_mock.check_assertions() + + +def test_delete_property_index_string_deprecation_warning( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """A raw-string index_name on delete_property_index warns; the enum form does not.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="DELETE", + ).respond_with_json({}, status=200) + + config = client_139.collections.use(COLLECTION).config + with pytest.warns(DeprecationWarning, match="Dep030"): + assert config.delete_property_index("name", "searchable") is True + + # the InvertedIndexType form is the supported path and must not warn + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert config.delete_property_index("name", InvertedIndexType.SEARCHABLE) is True + weaviate_139_mock.check_assertions() + + +def test_property_reindex_invalid_input( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """Invalid argument types raise WeaviateInvalidInputError before any request is sent.""" + config = client_139.collections.use(COLLECTION).config + + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + config.update_property_index("age", "rangeFilters", tenants=123) # type: ignore + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + config.rebuild_property_index("age", "rangeFilters", tenants=[1, 2]) # type: ignore + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + config.cancel_property_index_task(123, "searchable") # type: ignore + weaviate_139_mock.check_assertions() + + +def test_update_property_index_rejects_wand_algorithm( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """5c: WAND is never a valid target; both the enum and the wire string raise.""" + config = client_139.collections.use(COLLECTION).config + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError, match="not a valid target"): + config.update_property_index("name", "searchable", algorithm=BM25Algorithm.WAND) + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError, match="not a valid target"): + config.update_property_index("name", "searchable", algorithm="wand") # type: ignore + weaviate_139_mock.check_assertions() + + +def test_update_property_index_rejects_garbage_config_types( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """5d: non-str/enum tokenization or algorithm is rejected with a clear input error.""" + config = client_139.collections.use(COLLECTION).config + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + config.update_property_index("name", "searchable", tokenization=123) # type: ignore + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + config.update_property_index("name", "searchable", algorithm=123) # type: ignore + weaviate_139_mock.check_assertions() + + +@pytest.mark.asyncio +async def test_update_property_index_async( + weaviate_139_mock: HTTPServer, start_grpc_server: grpc.Server +) -> None: + """The async fork of update_property_index submits and waits by polling /indexes. + + Uses unordered handlers because the async client connects inside the test body (its startup + meta/nodes calls would collide with an ordered sequence); a ready-on-first-poll /indexes still + exercises the async convergence path. + """ + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="ready", tokenization="field") + ) + + async with weaviate.use_async_with_local( + host=MOCK_IP, port=MOCK_PORT, grpc_port=MOCK_PORT_GRPC + ) as client: + status = await client.collections.use(COLLECTION).config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.FIELD, + wait_for_completion=True, + ) + assert status.state == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + weaviate_139_mock.check_assertions() + + +@pytest.mark.asyncio +async def test_update_property_index_async_timeout( + weaviate_139_mock: HTTPServer, start_grpc_server: grpc.Server +) -> None: + """The async wait honors the timeout when the index never reaches ready.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "field"}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="indexing", taskId=TASK_ID, tokenization="word", targetTokenization="field") + ) + + async with weaviate.use_async_with_local( + host=MOCK_IP, port=MOCK_PORT, grpc_port=MOCK_PORT_GRPC + ) as client: + with pytest.raises(ReindexTimeoutError): + await client.collections.use(COLLECTION).config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.FIELD, + wait_for_completion=True, + timeout=0.5, + ) + weaviate_139_mock.check_assertions() + + +@pytest.mark.asyncio +async def test_rebuild_property_index_async( + weaviate_139_mock: HTTPServer, start_grpc_server: grpc.Server +) -> None: + """The async fork of rebuild_property_index submits a POST and returns the task.""" + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable/rebuild", + method="POST", + json={}, + ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + + async with weaviate.use_async_with_local( + host=MOCK_IP, port=MOCK_PORT, grpc_port=MOCK_PORT_GRPC + ) as client: + task = await client.collections.use(COLLECTION).config.rebuild_property_index( + "name", InvertedIndexType.SEARCHABLE + ) + assert task.task_id == TASK_ID + assert task.status == InvertedIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_property_reindex_unsupported_version( + weaviate_client: weaviate.WeaviateClient, +) -> None: + """Every new method raises against a server older than 1.39.0 (the mock advertises 1.36).""" + config = weaviate_client.collections.use(COLLECTION).config + + with pytest.raises(WeaviateUnsupportedFeatureError): + config.update_property_index("name", "searchable", tokenization=Tokenization.WORD) + with pytest.raises(WeaviateUnsupportedFeatureError): + config.rebuild_property_index("name", "searchable") + with pytest.raises(WeaviateUnsupportedFeatureError): + config.cancel_property_index_task("name", "searchable") + with pytest.raises(WeaviateUnsupportedFeatureError): + config.get_property_indexes() diff --git a/weaviate/classes/config.py b/weaviate/classes/config.py index c154062d3..5a4e591b7 100644 --- a/weaviate/classes/config.py +++ b/weaviate/classes/config.py @@ -1,9 +1,11 @@ from weaviate.collections.classes.config import ( + BM25Algorithm, Configure, ConsistencyLevel, DataType, GenerativeSearches, IndexName, + InvertedIndexType, PQEncoderDistribution, PQEncoderType, Property, @@ -26,6 +28,7 @@ from weaviate.connect.integrations import Integrations __all__ = [ + "BM25Algorithm", "Configure", "ConsistencyLevel", "Reconfigure", @@ -37,6 +40,7 @@ "MultiVectorAggregation", "ReplicationDeletionStrategy", "Property", + "InvertedIndexType", "PQEncoderDistribution", "PQEncoderType", "ReferenceProperty", diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 19396a7fc..e7efc7168 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -110,6 +110,12 @@ "high", ] +# Deprecated: this string alias is superseded by the ``InvertedIndexType`` enum and will be +# removed in a future release. Prefer ``InvertedIndexType`` for property index type arguments; +# the string form is still accepted by ``delete_property_index`` (which emits a +# ``DeprecationWarning``) for backwards compatibility. ``typing_extensions.deprecated`` (PEP 702) +# cannot annotate a bare type alias (it decorates classes/functions/overloads), so the +# deprecation is surfaced via this comment and the runtime warning on the accepting method. IndexName: TypeAlias = Literal[ "searchable", "filterable", @@ -117,6 +123,20 @@ ] +class InvertedIndexType(str, BaseEnum): + """The available property index types in Weaviate. + + Attributes: + SEARCHABLE: The searchable index, used for keyword (BM25) searches over text properties. + FILTERABLE: The filterable index, used for exact-match filtering. + RANGE_FILTERS: The rangeFilters index, used for range filtering. + """ + + SEARCHABLE = "searchable" + FILTERABLE = "filterable" + RANGE_FILTERS = "rangeFilters" + + class ConsistencyLevel(str, BaseEnum): """The consistency levels when writing to Weaviate with replication enabled. @@ -201,6 +221,18 @@ class Tokenization(str, BaseEnum): GSE_CH = "gse_ch" +class BM25Algorithm(str, BaseEnum): + """The BM25 scoring algorithm of a searchable property index. + + Attributes: + WAND: The Weak-AND scoring algorithm. + BLOCKMAX: The BlockMax-WAND scoring algorithm. + """ + + WAND = "wand" + BLOCKMAX = "blockmax" + + class GenerativeSearches(str, BaseEnum): """The available generative search modules in Weaviate. @@ -2258,6 +2290,110 @@ class _ShardStatus: ShardStatus = _ShardStatus +class InvertedIndexTaskStatus(str, BaseEnum): + """The status of a runtime property index task submission. + + Attributes: + STARTED: A reindexing task was submitted and started. + CANCELLED: A live reindexing task was cancelled. + NO_OP: No work was necessary, e.g. the index configuration already matched the request + or there was no live task to cancel. + """ + + STARTED = "STARTED" + CANCELLED = "CANCELLED" + NO_OP = "NO_OP" + + +class InvertedIndexState(str, BaseEnum): + """The state of a property index as reported by the index status endpoint. + + Attributes: + READY: The index is ready to serve queries. + PENDING: A reindexing task for the index is queued but has not started yet. + INDEXING: A reindexing task for the index is in progress. + FAILED: The reindexing task for the index failed. + CANCELLED: The reindexing task for the index was cancelled. + """ + + READY = "ready" + PENDING = "pending" + INDEXING = "indexing" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class _InvertedIndexTask(_ConfigBase): + """A submitted property index task. + + Known `status` values parse to `InvertedIndexTaskStatus`; unknown server values pass + through as plain strings, since the spec declares the field open-vocabulary. + """ + + task_id: Optional[str] + status: Union[InvertedIndexTaskStatus, str] + + +InvertedIndexTask = _InvertedIndexTask + + +@dataclass +class _InvertedIndexStatus(_ConfigBase): + """A snapshot of a single property index as reported by the index status endpoint. + + Known `state` values parse to `InvertedIndexState` and known `algorithm`/`target_algorithm` + values parse to `BM25Algorithm`; unknown server values pass through as plain strings, so that + polling the endpoint never crashes on a newly-added lifecycle state or scoring algorithm name. + """ + + type: InvertedIndexType # noqa: A003 + state: Union[InvertedIndexState, str] + progress: Optional[float] + task_id: Optional[str] + tokenization: Optional[Tokenization] + target_tokenization: Optional[Tokenization] + # searchable only: the current and in-flight BM25 scoring algorithm + algorithm: Optional[Union[BM25Algorithm, str]] + target_algorithm: Optional[Union[BM25Algorithm, str]] + + +InvertedIndexStatus = _InvertedIndexStatus + + +@dataclass +class _PropertyInvertedIndexes(_ConfigBase): + name: str + # For primitive properties the value matches the `DataType` str-enum (comparisons like + # `data_type == DataType.TEXT` work); reference properties instead carry the qualified + # target collection name (e.g. "Article"), which is why this is a plain str, not `DataType`. + data_type: str + description: Optional[str] + indexes: List[InvertedIndexStatus] + + def to_dict(self) -> Dict[str, Any]: + out = super().to_dict() + out["indexes"] = [index.to_dict() for index in self.indexes] + return out + + +PropertyInvertedIndexes = _PropertyInvertedIndexes + + +@dataclass +class _CollectionInvertedIndexes(_ConfigBase): + collection: str + properties: List[PropertyInvertedIndexes] + + def to_dict(self) -> Dict[str, Any]: + out = super().to_dict() + out["properties"] = [prop.to_dict() for prop in self.properties] + return out + + +CollectionInvertedIndexes = _CollectionInvertedIndexes + + class _TextAnalyzerConfigCreate(_ConfigCreateModel): """Text analysis options for a property. diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index 691cf208d..0710bcb35 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -2,8 +2,12 @@ from typing import Any, Dict, List, Optional, Union, cast from weaviate.collections.classes.config import ( + BM25Algorithm, DataType, GenerativeSearches, + InvertedIndexState, + InvertedIndexTaskStatus, + InvertedIndexType, PQEncoderDistribution, PQEncoderType, ReplicationDeletionStrategy, @@ -19,8 +23,11 @@ _BQConfig, _CollectionConfig, _CollectionConfigSimple, + _CollectionInvertedIndexes, _GenerativeConfig, _InvertedIndexConfig, + _InvertedIndexStatus, + _InvertedIndexTask, _MultiTenancyConfig, _MultiVectorConfig, _MuveraConfig, @@ -31,6 +38,7 @@ _PQConfig, _PQEncoderConfig, _Property, + _PropertyInvertedIndexes, _PropertyVectorizerConfig, _ReferenceProperty, _ReplicationConfig, @@ -558,3 +566,70 @@ def _references_from_config(schema: Dict[str, Any]) -> List[_ReferenceProperty]: for prop in schema["properties"] if not _is_primitive(prop["dataType"]) ] + + +def _inverted_index_task_from_json(response: Dict[str, Any]) -> _InvertedIndexTask: + raw_status = response["status"] + try: + status: Union[InvertedIndexTaskStatus, str] = InvertedIndexTaskStatus(raw_status) + except ValueError: + # the spec declares the field open-vocabulary; pass unknown values through + status = raw_status + return _InvertedIndexTask( + task_id=response.get("taskId"), + status=status, + ) + + +def _bm25_algorithm_or_raw(value: Optional[str]) -> Optional[Union[BM25Algorithm, str]]: + if value is None: + return None + try: + # known values parse to the enum; unknown ones pass through so that a future BM25 + # scoring algorithm never crashes a read + return BM25Algorithm(value) + except ValueError: + return value + + +def _inverted_index_status_from_json(index: Dict[str, Any]) -> _InvertedIndexStatus: + tokenization = index.get("tokenization") + target_tokenization = index.get("targetTokenization") + raw_state = index["status"] + try: + state: Union[InvertedIndexState, str] = InvertedIndexState(raw_state) + except ValueError: + # the spec declares the field open-vocabulary; pass unknown values through so that + # polling the endpoint never crashes on a newly-added lifecycle state name + state = raw_state + return _InvertedIndexStatus( + # `type` is closed-vocabulary and server-canonical (always filterable|searchable| + # rangeFilters), so parse it strictly into the enum, matching the file convention. + type=InvertedIndexType(index["type"]), + state=state, + progress=index.get("progress"), + task_id=index.get("taskId"), + tokenization=Tokenization(tokenization) if tokenization is not None else None, + target_tokenization=( + Tokenization(target_tokenization) if target_tokenization is not None else None + ), + algorithm=_bm25_algorithm_or_raw(index.get("algorithm")), + target_algorithm=_bm25_algorithm_or_raw(index.get("targetAlgorithm")), + ) + + +def _collection_inverted_indexes_from_json(response: Dict[str, Any]) -> _CollectionInvertedIndexes: + return _CollectionInvertedIndexes( + collection=response["collection"], + properties=[ + _PropertyInvertedIndexes( + name=prop["name"], + data_type=prop["dataType"], + description=prop.get("description"), + indexes=[ + _inverted_index_status_from_json(index) for index in prop.get("indexes") or [] + ], + ) + for prop in response.get("properties") or [] + ], + ) diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 015b70dab..d21230d1f 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -3,13 +3,19 @@ from typing import Dict, List, Literal, Optional, Union, overload from typing_extensions import deprecated from weaviate.collections.classes.config import ( + BM25Algorithm, CollectionConfig, CollectionConfigSimple, + CollectionInvertedIndexes, IndexName, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexType, Property, ReferenceProperty, ShardStatus, ShardTypes, + Tokenization, _GenerativeProvider, _InvertedIndexConfigUpdate, _MultiTenancyConfigUpdate, @@ -89,4 +95,54 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def add_vector( self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... - async def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... + async def delete_property_index( + self, property_name: str, index_name: Union[InvertedIndexType, IndexName] + ) -> bool: ... + @overload + async def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[True], + timeout: Optional[float] = None, + ) -> InvertedIndexStatus: ... + @overload + async def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, + ) -> InvertedIndexTask: ... + @overload + async def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[True], + timeout: Optional[float] = None, + ) -> InvertedIndexStatus: ... + @overload + async def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, + ) -> InvertedIndexTask: ... + async def cancel_property_index_task( + self, property_name: str, index_name: InvertedIndexType + ) -> InvertedIndexTask: ... + async def get_property_indexes(self) -> CollectionInvertedIndexes: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 103ab70ac..c0f231406 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -1,4 +1,6 @@ import asyncio +import time +from enum import Enum from typing import ( Any, Dict, @@ -18,14 +20,21 @@ from typing_extensions import deprecated from weaviate.collections.classes.config import ( + BM25Algorithm, CollectionConfig, CollectionConfigSimple, + CollectionInvertedIndexes, IndexName, + InvertedIndexState, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexType, Property, PropertyType, ReferenceProperty, ShardStatus, ShardTypes, + Tokenization, _CollectionConfigUpdate, _GenerativeProvider, _InvertedIndexConfigUpdate, @@ -45,6 +54,8 @@ from weaviate.collections.classes.config_methods import ( _collection_config_from_json, _collection_config_simple_from_json, + _collection_inverted_indexes_from_json, + _inverted_index_task_from_json, ) from weaviate.collections.classes.config_object_ttl import _ObjectTTLConfigUpdate from weaviate.collections.classes.config_vector_index import ( @@ -53,6 +64,9 @@ from weaviate.connect import executor from weaviate.connect.v4 import ConnectionAsync, ConnectionType, _ExpectedStatusCodes from weaviate.exceptions import ( + ReindexCanceledError, + ReindexFailedError, + ReindexTimeoutError, WeaviateInvalidInputError, WeaviateUnsupportedFeatureError, ) @@ -79,6 +93,68 @@ def _property_has_text_analyzer(prop: Property) -> bool: return any(_property_has_text_analyzer(np) for np in nested_list) +def _find_property_index_status( + indexes: CollectionInvertedIndexes, property_name: str, index_name: IndexName +) -> Optional[InvertedIndexStatus]: + for prop in indexes.properties: + if prop.name != property_name: + continue + for index in prop.indexes: + if index.type == index_name: + return index + return None + + +# A rebuild has no observable end-state on the index projection (the configuration does not +# change), so its wait is best-effort: if the task is never seen active, accept a ready entry after +# this many consecutive ready polls. We cannot positively observe a rebuild's completion, so a +# rebuild whose task is still queued on a lagging RAFT follower could report done up to roughly +# this many seconds early (dirkkul's original race, bounded). Kept generous to tolerate read lag. +_REINDEX_REBUILD_READY_GRACE_POLLS = 10 + +# No-progress bound: if the index entry stops advancing toward the requested state (vanishes, or +# sits ready on the OLD config) for this many consecutive polls, give up rather than hang forever +# (a server-side fault, e.g. an incomplete swap). Set well above RAFT read-lag and any brief +# transition window so a healthy migration NEVER trips it (any progressing poll resets the count). +_REINDEX_STALL_POLLS = 30 + +# Seconds between index-status polls. A module constant so tests can shrink it. +_REINDEX_POLL_INTERVAL_SECONDS = 1.0 + + +def _enum_value(value: Any) -> Any: + """Normalize a str-enum (Tokenization / BM25Algorithm) to its wire string for comparison.""" + return value.value if isinstance(value, Enum) else value + + +def _reindex_converged( + entry: InvertedIndexStatus, + expected_tokenization: Optional[str], + expected_algorithm: Optional[str], + is_rebuild: bool, + task_seen_active: bool, + ready_polls: int, +) -> bool: + """Decide whether a READY index ``entry`` reflects the completion of the submitted request. + + Caller guarantees ``entry.state == READY``. Compares by wire-string value so an enum-vs-string + mismatch cannot cause a false negative. + """ + # A migration still in flight shows its target in these fields (finalize / pre-flip window). + if entry.target_tokenization is not None or entry.target_algorithm is not None: + return False + if expected_tokenization is not None: + # A stale pre-flip ready still carries the OLD tokenization, so this won't match early. + return _enum_value(entry.tokenization) == expected_tokenization + if expected_algorithm is not None: + return _enum_value(entry.algorithm) == expected_algorithm + if is_rebuild: + # No visible end-state: accept once we saw the task active, or after a small ready grace. + return task_seen_active or ready_polls >= _REINDEX_REBUILD_READY_GRACE_POLLS + # A create with an empty body ({}) that returned 202: the index now exists and is ready. + return True + + class _ConfigCollectionExecutor(Generic[ConnectionType]): def __init__( self, @@ -629,7 +705,7 @@ async def _execute() -> None: def delete_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[InvertedIndexType, IndexName], ) -> executor.Result[bool]: """Delete a property index from the collection in Weaviate. @@ -638,31 +714,604 @@ def delete_property_index( Args: property_name: The property name from which to delete the index. - index_name: The type of the index to delete. + index_name: The type of the index to delete, an `InvertedIndexType` value. Passing a + raw string (`searchable`, `filterable` or `rangeFilters`) is deprecated but still + accepted. Raises: weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. weaviate.exceptions.WeaviateInvalidInputError: If the property or index does not exist. """ + if not isinstance(index_name, InvertedIndexType): + _Warnings.string_index_name_is_deprecated() + index = cast( + IndexName, + index_name.value if isinstance(index_name, InvertedIndexType) else index_name, + ) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] ) - _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index_name)]) + _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index)]) - path = ( + path = self.__property_index_path(property_name, index) + + def resp(res: Response) -> bool: + return res.status_code == 200 + + return executor.execute( + response_callback=resp, + method=self._connection.delete, + path=path, + error_msg="Property index may not have been deleted.", + status_codes=_ExpectedStatusCodes(ok_in=[200], error="Delete property index"), + ) + + def __check_property_reindex_support(self, feature: str) -> None: + if not self._connection._weaviate_version.is_at_least(1, 39, 0): + raise WeaviateUnsupportedFeatureError( + feature, + str(self._connection._weaviate_version), + "1.39.0", + ) + + def __property_index_path(self, property_name: str, index_name: IndexName) -> str: + return ( f"/schema/{_capitalize_first_letter(self._name)}" + f"/properties/{property_name}" + f"/index/{index_name}" ) - def resp(res: Response) -> bool: - return res.status_code == 200 + def __wait_for_property_index( + self, + property_name: str, + index_name: IndexName, + task: InvertedIndexTask, + timeout: Optional[float], + expected_tokenization: Optional[str], + expected_algorithm: Optional[str], + is_rebuild: bool, + ) -> executor.Result[InvertedIndexStatus]: + """Poll GET /schema/{class}/indexes until the submitted request converges, then return it. + + The index status endpoint is the completion signal (it authorizes on collection metadata, + the same access as the reindex operation itself, and strips the caller namespace from the + entry's ``task_id``). A ``NO_OP`` submission (no task id) means the configuration already + matched, so the current status is fetched once and returned without polling. Otherwise poll + every second: FAILED/CANCELLED on our task raise; a READY entry that matches the requested + state (see ``_reindex_converged``) is returned. The finalize window (``indexing`` at + progress 1.0) is never treated as done. + + A rebuild has no observable end-state, so its wait is best-effort: it returns once the entry + has been ready (having seen the task active, or after a consecutive-ready grace). + + The wait is bounded two ways: an explicit ``timeout`` (total seconds), and a no-progress + guard - if the entry stops advancing toward the requested state (vanishes, or sits ready on + the old config) for ``_REINDEX_STALL_POLLS`` consecutive polls, ``ReindexTimeoutError`` is + raised rather than hanging forever on a server-side fault. Any progressing poll resets it. + """ + task_id = task.task_id + + def deadline() -> Optional[float]: + return time.monotonic() + timeout if timeout is not None else None + + timeout_error = ReindexTimeoutError( + f"Timed out after {timeout}s waiting for the reindex of the '{index_name}' index of " + f"property '{property_name}' (task '{task_id}') to complete. Poll " + f"collection.config.get_property_indexes() to check on it." + ) + stall_error = ReindexTimeoutError( + f"The reindex of the '{index_name}' index of property '{property_name}' (task " + f"'{task_id}') did not progress toward the requested state and its entry is no longer " + f"advancing (the task may have failed server-side, e.g. an incomplete swap). Could not " + f"confirm completion; inspect collection.config.get_property_indexes() and GET /v1/tasks." + ) + no_op_missing_error = ReindexFailedError( + f"The configuration already matched (NO_OP) but the '{index_name}' index of property " + f"'{property_name}' is not present in get_property_indexes()." + ) + + def check(entry: Optional[InvertedIndexStatus], ready_polls: int) -> Tuple[int, bool]: + """Classify one poll. Returns (ready_polls, task_seen_active_this_poll). + + Raises on a failed/cancelled entry that belongs to our task. + """ + if entry is None: + return 0, False + seen_active = entry.task_id == task_id + if seen_active and entry.state == InvertedIndexState.FAILED: + raise ReindexFailedError( + f"Reindexing the '{index_name}' index of property '{property_name}' failed " + f"(task '{task_id}'). Inspect collection.config.get_property_indexes() for detail." + ) + if seen_active and entry.state == InvertedIndexState.CANCELLED: + raise ReindexCanceledError( + f"Reindexing the '{index_name}' index of property '{property_name}' was " + f"cancelled (task '{task_id}')." + ) + if entry.state == InvertedIndexState.READY: + ready_polls += 1 + else: + ready_polls = 0 + return ready_polls, seen_active + + def stalled(entry: Optional[InvertedIndexStatus], seen: bool) -> bool: + """Whether this poll counts as no-progress toward the requested state. + + Reset (returns False) when actively progressing: entry PENDING/INDEXING, or our task is + seen active. A ready rebuild entry (no requested config change) is left to the ready + grace, not counted as a stall. Otherwise - entry vanished, or a ready entry still on the + old config for a requested change - it is a stall. + """ + if entry is not None and ( + seen or entry.state in (InvertedIndexState.PENDING, InvertedIndexState.INDEXING) + ): + return False + if entry is not None and is_rebuild and entry.state == InvertedIndexState.READY: + return False + return True + + converged_args = (expected_tokenization, expected_algorithm, is_rebuild) + + if isinstance(self._connection, ConnectionAsync): + + async def _execute() -> InvertedIndexStatus: + if task_id is None: + indexes = await executor.aresult(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + if entry is None: + raise no_op_missing_error + return entry + limit = deadline() + task_seen_active = False + ready_polls = 0 + stall_polls = 0 + while True: + indexes = await executor.aresult(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + ready_polls, seen = check(entry, ready_polls) + task_seen_active = task_seen_active or seen + if ( + entry is not None + and entry.state == InvertedIndexState.READY + and _reindex_converged( + entry, *converged_args, task_seen_active, ready_polls + ) + ): + return entry + stall_polls = stall_polls + 1 if stalled(entry, seen) else 0 + if stall_polls >= _REINDEX_STALL_POLLS: + raise stall_error + if limit is not None: + remaining = limit - time.monotonic() + if remaining <= 0: + raise timeout_error + await asyncio.sleep(min(_REINDEX_POLL_INTERVAL_SECONDS, remaining)) + else: + await asyncio.sleep(_REINDEX_POLL_INTERVAL_SECONDS) + + return _execute() + + if task_id is None: + indexes = executor.result(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + if entry is None: + raise no_op_missing_error + return entry + limit = deadline() + task_seen_active = False + ready_polls = 0 + stall_polls = 0 + while True: + indexes = executor.result(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + ready_polls, seen = check(entry, ready_polls) + task_seen_active = task_seen_active or seen + if ( + entry is not None + and entry.state == InvertedIndexState.READY + and _reindex_converged(entry, *converged_args, task_seen_active, ready_polls) + ): + return entry + stall_polls = stall_polls + 1 if stalled(entry, seen) else 0 + if stall_polls >= _REINDEX_STALL_POLLS: + raise stall_error + if limit is not None: + remaining = limit - time.monotonic() + if remaining <= 0: + raise timeout_error + time.sleep(min(_REINDEX_POLL_INTERVAL_SECONDS, remaining)) + else: + time.sleep(_REINDEX_POLL_INTERVAL_SECONDS) + + def __submit_property_index_task( + self, + *, + property_name: str, + index_name: IndexName, + path_suffix: str, + http_method: Literal["PUT", "POST"], + body: Dict[str, Any], + tenants: Union[List[str], str, None], + wait_for_completion: bool, + timeout: Optional[float], + error_verb: str, + error_label: str, + ok_in: List[int], + ) -> executor.Result[Union[InvertedIndexTask, InvertedIndexStatus]]: + """Submit a reindex task (PUT upsert or POST rebuild) and optionally wait for it. + + Shared by ``update_property_index`` and ``rebuild_property_index``: input validation, + tenant csv encoding, the sync/async fork and the index-projection wait all live here. + """ + _validate_input( + [_ValidateArgument(expected=[str], name="property_name", value=property_name)] + ) + _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index_name)]) + _validate_input( + [_ValidateArgument(expected=[str, List[str], None], name="tenants", value=tenants)] + ) + _validate_input( + [ + _ValidateArgument( + expected=[bool], name="wait_for_completion", value=wait_for_completion + ) + ] + ) + _validate_input( + [_ValidateArgument(expected=[int, float, None], name="timeout", value=timeout)] + ) + + path = self.__property_index_path(property_name, index_name) + path_suffix + if isinstance(tenants, str): + tenants = [tenants] + # An empty tenant selection means "all tenants"; send no param rather than ?tenants= . + params: Optional[Dict[str, Any]] = {"tenants": ",".join(tenants)} if tenants else None + error_msg = f"Property index may not have been {error_verb}." + conn_method = self._connection.put if http_method == "PUT" else self._connection.post + # What was requested, as wire strings, so the wait can detect convergence on /indexes. + expected_tokenization = body.get("tokenization") + expected_algorithm = body.get("algorithm") + is_rebuild = path_suffix == "/rebuild" + + def resp(res: Response) -> InvertedIndexTask: + response = _decode_json_response_dict(res, error_label) + assert response is not None + return _inverted_index_task_from_json(response) + + if isinstance(self._connection, ConnectionAsync): + + async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: + res = await executor.aresult( + conn_method( + path=path, + weaviate_object=body, + params=params, + error_msg=error_msg, + status_codes=_ExpectedStatusCodes(ok_in=ok_in, error=error_label), + ) + ) + task = resp(res) + if wait_for_completion: + return await executor.aresult( + self.__wait_for_property_index( + property_name, + index_name, + task, + timeout, + expected_tokenization, + expected_algorithm, + is_rebuild, + ) + ) + return task + + return _execute() + res = executor.result( + conn_method( + path=path, + weaviate_object=body, + params=params, + error_msg=error_msg, + status_codes=_ExpectedStatusCodes(ok_in=ok_in, error=error_label), + ) + ) + task = resp(res) + if wait_for_completion: + return executor.result( + self.__wait_for_property_index( + property_name, + index_name, + task, + timeout, + expected_tokenization, + expected_algorithm, + is_rebuild, + ) + ) + return task + + @overload + def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[True], + timeout: Optional[float] = None, + ) -> executor.Result[InvertedIndexStatus]: ... + + @overload + def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, + ) -> executor.Result[InvertedIndexTask]: ... + + def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: bool = False, + timeout: Optional[float] = None, + ) -> executor.Result[Union[InvertedIndexTask, InvertedIndexStatus]]: + """Create or migrate a property index in this collection. + + Note: This method is a declarative upsert operation. If the index does not exist, it is created + with the requested configuration. If it exists, it is migrated towards the requested + configuration. If the configuration already matches, no work is submitted and the returned + task reports a `NO_OP` status. The server accepts at most one configuration change per request. + + Caution: changing `tokenization` via the `searchable` index ALSO retokenizes the property's + `filterable` index when one exists. Both indexes are migrated by a single coupled task (their + status entries share one `taskId`), and the retokenization changes how filters match on that + property. To retokenize only the filterable bucket, target the `filterable` index instead. + Cancelling via either index type cancels the whole coupled task. + Args: + property_name: The property whose index to create or migrate. + index_name: The type of the index, an `InvertedIndexType` value. + tokenization: The tokenization of the index. Required when creating a `searchable` index; + optional as a change on an existing `searchable` or `filterable` index. Not valid for + `rangeFilters`. + algorithm: The BM25 scoring algorithm of a `searchable` index. Only + `BM25Algorithm.BLOCKMAX` is a valid target (the `wand` to `blockmax` migration is + the only supported transition; the server rejects a request back to `wand`). + tenants: The tenant/list of tenants for which to create the index. Only valid when + creating a `rangeFilters` index on a multi-tenant collection. If not provided, all + tenants are affected. + wait_for_completion: Whether to poll the index status until the index reports ready for + the requested configuration. By default False. + timeout: When `wait_for_completion=True`, the maximum number of seconds to wait. `None` + (the default) waits while the task keeps progressing; a stalled or vanished task is + bounded by an internal no-progress guard, after which `ReindexTimeoutError` is raised. + + Returns: + A `InvertedIndexTask` when `wait_for_completion=False`, or the final `InvertedIndexStatus` + of the index when `wait_for_completion=True`. + + Raises: + weaviate.exceptions.WeaviateInvalidInputError: If the input parameters are invalid. + weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. + weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. + weaviate.exceptions.ReindexFailedError: If `wait_for_completion=True` and the reindexing task failed. + weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. + weaviate.exceptions.ReindexTimeoutError: If `wait_for_completion=True` and `timeout` is exceeded. + """ + self.__check_property_reindex_support("Collection config update_property_index") + # 5d: type validation (keeps enum / wire-string / None leniency, rejects genuine garbage). + _validate_input( + [ + _ValidateArgument( + expected=[Tokenization, str, None], name="tokenization", value=tokenization + ) + ] + ) + _validate_input( + [ + _ValidateArgument( + expected=[BM25Algorithm, str, None], name="algorithm", value=algorithm + ) + ] + ) + index = cast( + IndexName, + index_name.value if isinstance(index_name, InvertedIndexType) else index_name, + ) + body: Dict[str, Any] = {} + if tokenization is not None: + body["tokenization"] = ( + tokenization.value if isinstance(tokenization, Tokenization) else tokenization + ) + if algorithm is not None: + algorithm_value = algorithm.value if isinstance(algorithm, BM25Algorithm) else algorithm + # 5c: WAND is never a valid target (the server always 400s); reject it clearly. + if algorithm_value == BM25Algorithm.WAND.value: + raise WeaviateInvalidInputError( + "algorithm=WAND is not a valid target; only BM25Algorithm.BLOCKMAX is supported " + "(wand->blockmax is the only transition; downgrade is not supported)." + ) + body["algorithm"] = algorithm_value + return self.__submit_property_index_task( + property_name=property_name, + index_name=index, + path_suffix="", + http_method="PUT", + body=body, + tenants=tenants, + wait_for_completion=wait_for_completion, + timeout=timeout, + error_verb="updated", + error_label="Update property index", + ok_in=[200, 202], + ) + + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[True], + timeout: Optional[float] = None, + ) -> executor.Result[InvertedIndexStatus]: ... + + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, + ) -> executor.Result[InvertedIndexTask]: ... + + def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: bool = False, + timeout: Optional[float] = None, + ) -> executor.Result[Union[InvertedIndexTask, InvertedIndexStatus]]: + """Rebuild an existing property index from scratch with its current configuration. + + Args: + property_name: The property whose index to rebuild. + index_name: The type of the index, an `InvertedIndexType` value. + tenants: The tenant/list of tenants for which to rebuild the index on a multi-tenant + collection. If not provided, all tenants are affected. + wait_for_completion: Whether to poll the index status until the rebuild is done. By + default False. Because a rebuild does not change the index configuration it has no + observable end-state, so this wait is best-effort: it returns once the index has + reported ready. + timeout: When `wait_for_completion=True`, the maximum number of seconds to wait. `None` + (the default) waits while the task keeps progressing; a stalled or vanished task is + bounded by an internal no-progress guard, after which `ReindexTimeoutError` is raised. + + Returns: + A `InvertedIndexTask` when `wait_for_completion=False`, or the final `InvertedIndexStatus` + of the index when `wait_for_completion=True`. + + Raises: + weaviate.exceptions.WeaviateInvalidInputError: If the input parameters are invalid. + weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. + weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. + weaviate.exceptions.ReindexFailedError: If `wait_for_completion=True` and the reindexing task failed. + weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. + weaviate.exceptions.ReindexTimeoutError: If `wait_for_completion=True` and `timeout` is exceeded. + """ + self.__check_property_reindex_support("Collection config rebuild_property_index") + index = cast( + IndexName, + index_name.value if isinstance(index_name, InvertedIndexType) else index_name, + ) + return self.__submit_property_index_task( + property_name=property_name, + index_name=index, + path_suffix="/rebuild", + http_method="POST", + body={}, + tenants=tenants, + wait_for_completion=wait_for_completion, + timeout=timeout, + error_verb="rebuilt", + error_label="Rebuild property index", + ok_in=[202], + ) + + def cancel_property_index_task( + self, + property_name: str, + index_name: InvertedIndexType, + ) -> executor.Result[InvertedIndexTask]: + """Cancel the live reindexing task of a property index. + + This operation is idempotent: if there is no live task for the index, the returned task + reports a `NO_OP` status. Note that a coupled tokenization change (a `searchable` change on a + property that also has a `filterable` index) is a single task; cancelling it via either index + type cancels the whole task. + + Args: + property_name: The property whose reindexing task to cancel. + index_name: The type of the index, an `InvertedIndexType` value. + + Returns: + A `InvertedIndexTask` with status `CANCELLED` if a live task was cancelled or `NO_OP` otherwise. + + Raises: + weaviate.exceptions.WeaviateInvalidInputError: If the input parameters are invalid. + weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. + weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. + """ + self.__check_property_reindex_support("Collection config cancel_property_index_task") + index = cast( + IndexName, + index_name.value if isinstance(index_name, InvertedIndexType) else index_name, + ) + _validate_input( + [_ValidateArgument(expected=[str], name="property_name", value=property_name)] + ) + _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index)]) + + path = self.__property_index_path(property_name, index) + "/cancel" + + def resp(res: Response) -> InvertedIndexTask: + response = _decode_json_response_dict(res, "Cancel property index task") + assert response is not None + return _inverted_index_task_from_json(response) + + # Cancel is always 202 in merged core: CANCELLED when a live task was stopped, NO_OP otherwise. return executor.execute( response_callback=resp, - method=self._connection.delete, + method=self._connection.post, path=path, - error_msg="Property may not exist", - status_codes=_ExpectedStatusCodes(ok_in=[200], error="property exists"), + weaviate_object={}, + error_msg="Property index task may not have been cancelled.", + status_codes=_ExpectedStatusCodes(ok_in=[202], error="Cancel property index task"), + ) + + def get_property_indexes(self) -> executor.Result[CollectionInvertedIndexes]: + """Get the statuses of the property indexes of this collection. + + The response includes the state of any in-flight reindexing tasks, e.g. their progress and + target configuration. Poll this endpoint for a `ready` status to detect the completion of a + reindexing task. + + Returns: + A `CollectionInvertedIndexes` object containing the index statuses grouped by property. + + Raises: + weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. + weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. + """ + self.__check_property_reindex_support("Collection config get_property_indexes") + + def resp(res: Response) -> CollectionInvertedIndexes: + response = _decode_json_response_dict(res, "Get property indexes") + assert response is not None + return _collection_inverted_indexes_from_json(response) + + return executor.execute( + response_callback=resp, + method=self._connection.get, + path=f"/schema/{_capitalize_first_letter(self._name)}/indexes", + error_msg="Property index statuses could not be retrieved.", + status_codes=_ExpectedStatusCodes(ok_in=[200], error="Get property indexes"), ) diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index e54d8c8fc..ccfa861e5 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -3,13 +3,19 @@ from typing import Dict, List, Literal, Optional, Union, overload from typing_extensions import deprecated from weaviate.collections.classes.config import ( + BM25Algorithm, CollectionConfig, CollectionConfigSimple, + CollectionInvertedIndexes, IndexName, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexType, Property, ReferenceProperty, ShardStatus, ShardTypes, + Tokenization, _GenerativeProvider, _InvertedIndexConfigUpdate, _MultiTenancyConfigUpdate, @@ -87,4 +93,54 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def add_vector( self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... - def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... + def delete_property_index( + self, property_name: str, index_name: Union[InvertedIndexType, IndexName] + ) -> bool: ... + @overload + def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[True], + timeout: Optional[float] = None, + ) -> InvertedIndexStatus: ... + @overload + def update_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[BM25Algorithm] = None, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, + ) -> InvertedIndexTask: ... + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[True], + timeout: Optional[float] = None, + ) -> InvertedIndexStatus: ... + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: InvertedIndexType, + *, + tenants: Union[List[str], str, None] = None, + wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, + ) -> InvertedIndexTask: ... + def cancel_property_index_task( + self, property_name: str, index_name: InvertedIndexType + ) -> InvertedIndexTask: ... + def get_property_indexes(self) -> CollectionInvertedIndexes: ... diff --git a/weaviate/exceptions.py b/weaviate/exceptions.py index ce0fe6f7e..5eca4b7e4 100644 --- a/weaviate/exceptions.py +++ b/weaviate/exceptions.py @@ -149,6 +149,18 @@ class ExportCanceledError(WeaviateBaseError): """Export Canceled Exception.""" +class ReindexFailedError(WeaviateBaseError): + """Reindex Failed Exception.""" + + +class ReindexCanceledError(WeaviateBaseError): + """Reindex Canceled Exception.""" + + +class ReindexTimeoutError(WeaviateBaseError): + """Is raised when waiting for a reindex task to complete exceeds the requested timeout.""" + + class EmptyResponseError(WeaviateBaseError): """Occurs when an HTTP request unexpectedly returns an empty response.""" diff --git a/weaviate/outputs/config.py b/weaviate/outputs/config.py index 17ebebf0e..d6157edc3 100644 --- a/weaviate/outputs/config.py +++ b/weaviate/outputs/config.py @@ -1,17 +1,25 @@ from weaviate.collections.classes.config import ( AsyncReplicationConfig, + BM25Algorithm, BM25Config, CollectionConfig, CollectionConfigSimple, + CollectionInvertedIndexes, GenerativeConfig, GenerativeSearches, InvertedIndexConfig, + InvertedIndexState, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexTaskStatus, + InvertedIndexType, MultiTenancyConfig, PQConfig, PQEncoderConfig, PQEncoderDistribution, PQEncoderType, PropertyConfig, + PropertyInvertedIndexes, PropertyType, ReferencePropertyConfig, ReplicationConfig, @@ -32,12 +40,19 @@ __all__ = [ "AsyncReplicationConfig", + "BM25Algorithm", "BM25Config", "CollectionConfig", "CollectionConfigSimple", + "CollectionInvertedIndexes", "GenerativeConfig", "GenerativeSearches", "InvertedIndexConfig", + "InvertedIndexState", + "InvertedIndexStatus", + "InvertedIndexTask", + "InvertedIndexTaskStatus", + "InvertedIndexType", "MultiTenancyConfig", "ReplicationDeletionStrategy", "PQConfig", @@ -45,6 +60,7 @@ "PQEncoderDistribution", "PQEncoderType", "PropertyConfig", + "PropertyInvertedIndexes", "PropertyType", "ReferencePropertyConfig", "ReplicationConfig", diff --git a/weaviate/warnings.py b/weaviate/warnings.py index 1c0a1ae0b..f2df46d57 100644 --- a/weaviate/warnings.py +++ b/weaviate/warnings.py @@ -261,6 +261,15 @@ def async_replication_field_removed_server_side(argument: str) -> None: stacklevel=1, ) + @staticmethod + def string_index_name_is_deprecated() -> None: + warnings.warn( + message="""Dep030: Passing a string `index_name` is deprecated and will be removed in a future release. + Pass an `InvertedIndexType` value instead (e.g. `InvertedIndexType.SEARCHABLE`).""", + category=DeprecationWarning, + stacklevel=3, + ) + @staticmethod def datetime_insertion_with_no_specified_timezone(date: datetime) -> None: warnings.warn(