From 5e7179c705b179ea5ad80762320c352ceebf34a1 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:49:14 +0200 Subject: [PATCH 01/14] feat: add runtime property reindex API for Weaviate >= 1.39 (#2097) Adds collection.config methods for the GA runtime property reindex API: - update_property_index: declarative create-or-migrate upsert (PUT /schema/{class}/properties/{prop}/index/{indexType}) with optional wait_for_completion polling of the index status endpoint - rebuild_property_index: rebuild an existing index from scratch, with tenant selection and optional wait_for_completion - cancel_property_index_task: idempotent cancellation of a live task - get_property_indexes: parse GET /schema/{class}/indexes into new CollectionPropertyIndexes/PropertyIndexStatus read-side types All methods raise WeaviateUnsupportedFeatureError below server 1.39.0. --- weaviate/collections/classes/config.py | 77 ++++ .../collections/classes/config_methods.py | 53 +++ weaviate/collections/config/async_.pyi | 50 ++- weaviate/collections/config/executor.py | 374 ++++++++++++++++++ weaviate/collections/config/sync.pyi | 50 ++- weaviate/exceptions.py | 8 + weaviate/outputs/config.py | 12 + 7 files changed, 622 insertions(+), 2 deletions(-) diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 0f6d974c0..3fdc34d4a 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -2207,6 +2207,83 @@ class _ShardStatus: ShardStatus = _ShardStatus +class PropertyIndexTaskStatus(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 PropertyIndexState(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 _PropertyIndexTask(_ConfigBase): + task_id: Optional[str] + status: PropertyIndexTaskStatus + + +PropertyIndexTask = _PropertyIndexTask + + +@dataclass +class _PropertyIndexStatus(_ConfigBase): + type: IndexName # noqa: A003 + status: PropertyIndexState + progress: Optional[float] + task_id: Optional[str] + tokenization: Optional[Tokenization] + target_tokenization: Optional[Tokenization] + algorithm: Optional[str] + target_algorithm: Optional[str] + + +PropertyIndexStatus = _PropertyIndexStatus + + +@dataclass +class _PropertyIndexes(_ConfigBase): + name: str + data_type: str + description: Optional[str] + indexes: List[PropertyIndexStatus] + + +PropertyIndexes = _PropertyIndexes + + +@dataclass +class _CollectionPropertyIndexes(_ConfigBase): + collection: str + properties: List[PropertyIndexes] + + +CollectionPropertyIndexes = _CollectionPropertyIndexes + + 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..01981d9af 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -4,8 +4,11 @@ from weaviate.collections.classes.config import ( DataType, GenerativeSearches, + IndexName, PQEncoderDistribution, PQEncoderType, + PropertyIndexState, + PropertyIndexTaskStatus, ReplicationDeletionStrategy, Rerankers, StopwordsPreset, @@ -19,6 +22,7 @@ _BQConfig, _CollectionConfig, _CollectionConfigSimple, + _CollectionPropertyIndexes, _GenerativeConfig, _InvertedIndexConfig, _MultiTenancyConfig, @@ -31,6 +35,9 @@ _PQConfig, _PQEncoderConfig, _Property, + _PropertyIndexes, + _PropertyIndexStatus, + _PropertyIndexTask, _PropertyVectorizerConfig, _ReferenceProperty, _ReplicationConfig, @@ -558,3 +565,49 @@ def _references_from_config(schema: Dict[str, Any]) -> List[_ReferenceProperty]: for prop in schema["properties"] if not _is_primitive(prop["dataType"]) ] + + +def _property_index_task_from_json(response: Dict[str, Any]) -> _PropertyIndexTask: + return _PropertyIndexTask( + task_id=response.get("taskId"), + status=PropertyIndexTaskStatus(response["status"]), + ) + + +def _property_index_status_from_json(index: Dict[str, Any]) -> _PropertyIndexStatus: + tokenization = index.get("tokenization") + target_tokenization = index.get("targetTokenization") + return _PropertyIndexStatus( + type=cast(IndexName, index["type"]), + status=PropertyIndexState(index["status"]), + 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=index.get("algorithm"), + target_algorithm=index.get("targetAlgorithm"), + ) + + +def _collection_property_indexes_from_json(response: Dict[str, Any]) -> _CollectionPropertyIndexes: + properties: List[_PropertyIndexes] = [] + for prop in response.get("properties") or []: + data_type = prop.get("dataType") + if isinstance(data_type, list): + data_type = data_type[0] if len(data_type) > 0 else "" + properties.append( + _PropertyIndexes( + name=prop["name"], + data_type=cast(str, data_type), + description=prop.get("description"), + indexes=[ + _property_index_status_from_json(index) for index in prop.get("indexes") or [] + ], + ) + ) + return _CollectionPropertyIndexes( + collection=response["collection"], + properties=properties, + ) diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 015b70dab..3fe2ef26f 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -1,15 +1,19 @@ -from typing import Dict, List, Literal, Optional, Union, overload +from typing import Dict, List, Literal, Optional, Sequence, Union, overload from typing_extensions import deprecated from weaviate.collections.classes.config import ( CollectionConfig, CollectionConfigSimple, + CollectionPropertyIndexes, IndexName, Property, + PropertyIndexStatus, + PropertyIndexTask, ReferenceProperty, ShardStatus, ShardTypes, + Tokenization, _GenerativeProvider, _InvertedIndexConfigUpdate, _MultiTenancyConfigUpdate, @@ -90,3 +94,47 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... async def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... + @overload + async def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[True], + ) -> PropertyIndexStatus: ... + @overload + async def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[False] = False, + ) -> PropertyIndexTask: ... + @overload + async def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[True], + ) -> PropertyIndexStatus: ... + @overload + async def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[False] = False, + ) -> PropertyIndexTask: ... + async def cancel_property_index_task( + self, property_name: str, index_name: IndexName + ) -> PropertyIndexTask: ... + async def get_property_indexes(self) -> CollectionPropertyIndexes: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 103ab70ac..5c7d35a75 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -1,4 +1,5 @@ import asyncio +import time from typing import ( Any, Dict, @@ -20,12 +21,17 @@ from weaviate.collections.classes.config import ( CollectionConfig, CollectionConfigSimple, + CollectionPropertyIndexes, IndexName, Property, + PropertyIndexState, + PropertyIndexStatus, + PropertyIndexTask, PropertyType, ReferenceProperty, ShardStatus, ShardTypes, + Tokenization, _CollectionConfigUpdate, _GenerativeProvider, _InvertedIndexConfigUpdate, @@ -45,6 +51,8 @@ from weaviate.collections.classes.config_methods import ( _collection_config_from_json, _collection_config_simple_from_json, + _collection_property_indexes_from_json, + _property_index_task_from_json, ) from weaviate.collections.classes.config_object_ttl import _ObjectTTLConfigUpdate from weaviate.collections.classes.config_vector_index import ( @@ -53,6 +61,8 @@ from weaviate.connect import executor from weaviate.connect.v4 import ConnectionAsync, ConnectionType, _ExpectedStatusCodes from weaviate.exceptions import ( + ReindexCanceledError, + ReindexFailedError, WeaviateInvalidInputError, WeaviateUnsupportedFeatureError, ) @@ -79,6 +89,37 @@ 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: CollectionPropertyIndexes, property_name: str, index_name: IndexName +) -> Optional[PropertyIndexStatus]: + 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 + + +def _terminal_property_index_status( + entry: Optional[PropertyIndexStatus], property_name: str, index_name: IndexName +) -> Optional[PropertyIndexStatus]: + """Return the entry once it is ready, raise on failure/cancellation, or return None to keep polling.""" + if entry is None: + return None + if entry.status == PropertyIndexState.READY: + return entry + if entry.status == PropertyIndexState.FAILED: + raise ReindexFailedError( + f"Reindexing the '{index_name}' index of property '{property_name}' failed." + ) + if entry.status == PropertyIndexState.CANCELLED: + raise ReindexCanceledError( + f"Reindexing the '{index_name}' index of property '{property_name}' was cancelled." + ) + return None + + class _ConfigCollectionExecutor(Generic[ConnectionType]): def __init__( self, @@ -666,3 +707,336 @@ def resp(res: Response) -> bool: error_msg="Property may not exist", status_codes=_ExpectedStatusCodes(ok_in=[200], error="property exists"), ) + + 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 __wait_for_property_index( + self, property_name: str, index_name: IndexName + ) -> executor.Result[PropertyIndexStatus]: + if isinstance(self._connection, ConnectionAsync): + + async def _execute() -> PropertyIndexStatus: + while True: + indexes = await executor.aresult(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + done = _terminal_property_index_status(entry, property_name, index_name) + if done is not None: + return done + await asyncio.sleep(1) + + return _execute() + while True: + indexes = executor.result(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + done = _terminal_property_index_status(entry, property_name, index_name) + if done is not None: + return done + time.sleep(1) + + @overload + def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[True], + ) -> executor.Result[PropertyIndexStatus]: ... + + @overload + def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[False] = False, + ) -> executor.Result[PropertyIndexTask]: ... + + def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: bool = False, + ) -> executor.Result[Union[PropertyIndexTask, PropertyIndexStatus]]: + """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. + + Args: + property_name: The property whose index to create or migrate. + index_name: The type of the index, one of `searchable`, `filterable` or `rangeFilters`. + 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 search algorithm of a `searchable` index. Only `blockmax` may be requested. + tenants: The 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 wait until the index reports `ready`. By default False. + + Returns: + A `PropertyIndexTask` when `wait_for_completion=False`, or the final `PropertyIndexStatus` + of the index when `wait_for_completion=True`. + + Raises: + 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. + """ + self.__check_property_reindex_support("Collection config update_property_index") + _validate_input( + [_ValidateArgument(expected=[str], name="property_name", value=property_name)] + ) + _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index_name)]) + + path = self.__property_index_path(property_name, 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: + body["algorithm"] = algorithm + params: Optional[Dict[str, Any]] = ( + {"tenants": ",".join(tenants)} if tenants is not None else None + ) + + def resp(res: Response) -> PropertyIndexTask: + response = _decode_json_response_dict(res, "Update property index") + assert response is not None + return _property_index_task_from_json(response) + + if isinstance(self._connection, ConnectionAsync): + + async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: + res = await executor.aresult( + self._connection.put( + path=path, + weaviate_object=body, + params=params, + error_msg="Property index may not have been updated.", + status_codes=_ExpectedStatusCodes( + ok_in=[200, 202], error="Update property index" + ), + ) + ) + task = resp(res) + if wait_for_completion: + return await executor.aresult( + self.__wait_for_property_index(property_name, index_name) + ) + return task + + return _execute() + res = executor.result( + self._connection.put( + path=path, + weaviate_object=body, + params=params, + error_msg="Property index may not have been updated.", + status_codes=_ExpectedStatusCodes(ok_in=[200, 202], error="Update property index"), + ) + ) + task = resp(res) + if wait_for_completion: + return executor.result(self.__wait_for_property_index(property_name, index_name)) + return task + + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[True], + ) -> executor.Result[PropertyIndexStatus]: ... + + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[False] = False, + ) -> executor.Result[PropertyIndexTask]: ... + + def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: bool = False, + ) -> executor.Result[Union[PropertyIndexTask, PropertyIndexStatus]]: + """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, one of `searchable`, `filterable` or `rangeFilters`. + tenants: The tenants for which to rebuild the index on a multi-tenant collection. + If not provided, all tenants are affected. + wait_for_completion: Whether to wait until the index reports `ready`. By default False. + + Returns: + A `PropertyIndexTask` when `wait_for_completion=False`, or the final `PropertyIndexStatus` + of the index when `wait_for_completion=True`. + + Raises: + 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. + """ + self.__check_property_reindex_support("Collection config rebuild_property_index") + _validate_input( + [_ValidateArgument(expected=[str], name="property_name", value=property_name)] + ) + _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index_name)]) + + path = self.__property_index_path(property_name, index_name) + "/rebuild" + params: Optional[Dict[str, Any]] = ( + {"tenants": ",".join(tenants)} if tenants is not None else None + ) + + def resp(res: Response) -> PropertyIndexTask: + response = _decode_json_response_dict(res, "Rebuild property index") + assert response is not None + return _property_index_task_from_json(response) + + if isinstance(self._connection, ConnectionAsync): + + async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: + res = await executor.aresult( + self._connection.post( + path=path, + weaviate_object={}, + params=params, + error_msg="Property index may not have been rebuilt.", + status_codes=_ExpectedStatusCodes( + ok_in=[202], error="Rebuild property index" + ), + ) + ) + task = resp(res) + if wait_for_completion: + return await executor.aresult( + self.__wait_for_property_index(property_name, index_name) + ) + return task + + return _execute() + res = executor.result( + self._connection.post( + path=path, + weaviate_object={}, + params=params, + error_msg="Property index may not have been rebuilt.", + status_codes=_ExpectedStatusCodes(ok_in=[202], error="Rebuild property index"), + ) + ) + task = resp(res) + if wait_for_completion: + return executor.result(self.__wait_for_property_index(property_name, index_name)) + return task + + def cancel_property_index_task( + self, + property_name: str, + index_name: IndexName, + ) -> executor.Result[PropertyIndexTask]: + """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, one of `searchable`, `filterable` or `rangeFilters`. + + Returns: + A `PropertyIndexTask` with status `CANCELLED` if a live task was cancelled or `NO_OP` otherwise. + + 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 cancel_property_index_task") + _validate_input( + [_ValidateArgument(expected=[str], name="property_name", value=property_name)] + ) + _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index_name)]) + + path = self.__property_index_path(property_name, index_name) + "/cancel" + + def resp(res: Response) -> PropertyIndexTask: + response = _decode_json_response_dict(res, "Cancel property index task") + assert response is not None + return _property_index_task_from_json(response) + + return executor.execute( + response_callback=resp, + method=self._connection.post, + path=path, + 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[CollectionPropertyIndexes]: + """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 `CollectionPropertyIndexes` 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) -> CollectionPropertyIndexes: + response = _decode_json_response_dict(res, "Get property indexes") + assert response is not None + return _collection_property_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..87a87f625 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -1,15 +1,19 @@ -from typing import Dict, List, Literal, Optional, Union, overload +from typing import Dict, List, Literal, Optional, Sequence, Union, overload from typing_extensions import deprecated from weaviate.collections.classes.config import ( CollectionConfig, CollectionConfigSimple, + CollectionPropertyIndexes, IndexName, Property, + PropertyIndexStatus, + PropertyIndexTask, ReferenceProperty, ShardStatus, ShardTypes, + Tokenization, _GenerativeProvider, _InvertedIndexConfigUpdate, _MultiTenancyConfigUpdate, @@ -88,3 +92,47 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... + @overload + def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[True], + ) -> PropertyIndexStatus: ... + @overload + def update_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tokenization: Optional[Tokenization] = None, + algorithm: Optional[Literal["blockmax"]] = None, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[False] = False, + ) -> PropertyIndexTask: ... + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[True], + ) -> PropertyIndexStatus: ... + @overload + def rebuild_property_index( + self, + property_name: str, + index_name: IndexName, + *, + tenants: Optional[Sequence[str]] = None, + wait_for_completion: Literal[False] = False, + ) -> PropertyIndexTask: ... + def cancel_property_index_task( + self, property_name: str, index_name: IndexName + ) -> PropertyIndexTask: ... + def get_property_indexes(self) -> CollectionPropertyIndexes: ... diff --git a/weaviate/exceptions.py b/weaviate/exceptions.py index ce0fe6f7e..a1bcaf6a9 100644 --- a/weaviate/exceptions.py +++ b/weaviate/exceptions.py @@ -149,6 +149,14 @@ class ExportCanceledError(WeaviateBaseError): """Export Canceled Exception.""" +class ReindexFailedError(WeaviateBaseError): + """Reindex Failed Exception.""" + + +class ReindexCanceledError(WeaviateBaseError): + """Reindex Canceled Exception.""" + + 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..b708d0a63 100644 --- a/weaviate/outputs/config.py +++ b/weaviate/outputs/config.py @@ -3,6 +3,7 @@ BM25Config, CollectionConfig, CollectionConfigSimple, + CollectionPropertyIndexes, GenerativeConfig, GenerativeSearches, InvertedIndexConfig, @@ -12,6 +13,11 @@ PQEncoderDistribution, PQEncoderType, PropertyConfig, + PropertyIndexes, + PropertyIndexState, + PropertyIndexStatus, + PropertyIndexTask, + PropertyIndexTaskStatus, PropertyType, ReferencePropertyConfig, ReplicationConfig, @@ -35,6 +41,7 @@ "BM25Config", "CollectionConfig", "CollectionConfigSimple", + "CollectionPropertyIndexes", "GenerativeConfig", "GenerativeSearches", "InvertedIndexConfig", @@ -45,6 +52,11 @@ "PQEncoderDistribution", "PQEncoderType", "PropertyConfig", + "PropertyIndexes", + "PropertyIndexState", + "PropertyIndexStatus", + "PropertyIndexTask", + "PropertyIndexTaskStatus", "PropertyType", "ReferencePropertyConfig", "ReplicationConfig", From 762e656d3975600974268d490413f3e5fbef5ca3 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:49:30 +0200 Subject: [PATCH 02/14] test: cover runtime property reindex endpoints (#2097) Mock tests exercise the exact REST routes against a 1.39-advertising mock server (upsert 202/NO_OP, rebuild, cancel CANCELLED/NO_OP, tenants csv encoding, status parsing incl. coupled task entries) and assert WeaviateUnsupportedFeatureError against a 1.36 mock. Integration tests cover the searchable lifecycle, rangeFilters creation, coupled tokenization changes, multi-tenant selection and the async client, skipping below server 1.39.0. --- integration/test_collection_config.py | 224 ++++++++++++++++++++ mock_tests/test_property_reindex.py | 289 ++++++++++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 mock_tests/test_property_reindex.py diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index 814c5d41a..fc9bcb4cf 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,6 +41,8 @@ _NamedVectorConfigCreate, _VectorizerConfigCreate, IndexName, + PropertyIndexState, + PropertyIndexTaskStatus, ) from weaviate.collections.classes.tenants import Tenant from weaviate.exceptions import ( @@ -2678,3 +2681,224 @@ 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", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + assert status.type == "searchable" + assert status.status == PropertyIndexState.READY + assert status.tokenization == Tokenization.WORD + + # re-putting the matching configuration is a no-op + task = collection.config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD + ) + assert task.status == PropertyIndexTaskStatus.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.status == PropertyIndexState.READY + + # rebuild the index from scratch + status = collection.config.rebuild_property_index( + "name", "searchable", wait_for_completion=True + ) + assert status.type == "searchable" + assert status.status == PropertyIndexState.READY + + # cancelling when no task is live is an idempotent no-op + task = collection.config.cancel_property_index_task("name", "searchable") + assert task.status == PropertyIndexTaskStatus.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", "rangeFilters", wait_for_completion=True + ) + assert status.type == "rangeFilters" + assert status.status == PropertyIndexState.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.status == PropertyIndexState.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", "searchable", tokenization=Tokenization.FIELD + ) + assert task.status == PropertyIndexTaskStatus.STARTED + assert task.task_id is not None + + 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 (via the wait path of a NO_OP upsert) until the migration is done + status = collection.config.update_property_index( + "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + ) + assert status.status == PropertyIndexState.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.status == PropertyIndexState.READY + assert filterable.tokenization == Tokenization.FIELD + + +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", "rangeFilters", tenants=["tenant1", "tenant2"], wait_for_completion=True + ) + assert status.type == "rangeFilters" + assert status.status == PropertyIndexState.READY + + status = collection.config.rebuild_property_index( + "age", "rangeFilters", tenants=["tenant1"], wait_for_completion=True + ) + assert status.type == "rangeFilters" + assert status.status == PropertyIndexState.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", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + assert status.type == "searchable" + assert status.status == PropertyIndexState.READY + + task = await collection.config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD + ) + assert task.status == PropertyIndexTaskStatus.NO_OP + + indexes = await collection.config.get_property_indexes() + assert indexes.collection == collection.name + + status = await collection.config.rebuild_property_index( + "name", "searchable", wait_for_completion=True + ) + assert status.status == PropertyIndexState.READY + + task = await collection.config.cancel_property_index_task("name", "searchable") + assert task.status == PropertyIndexTaskStatus.NO_OP diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py new file mode 100644 index 000000000..84ddd4f3d --- /dev/null +++ b/mock_tests/test_property_reindex.py @@ -0,0 +1,289 @@ +import json +from typing import Generator + +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.classes.config import ( + PropertyIndexState, + PropertyIndexTaskStatus, + Tokenization, +) +from weaviate.exceptions import WeaviateUnsupportedFeatureError + +COLLECTION = "TestCollection" +SCHEMA_PATH = f"/v1/schema/{COLLECTION}" +TASK_ID = "00000000-0000-0000-0000-000000000001" + + +@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: + weaviate_139_mock.expect_request( + f"{SCHEMA_PATH}/properties/name/index/searchable", + method="PUT", + json={"tokenization": "word", "algorithm": "blockmax"}, + ).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, + algorithm="blockmax", + ) + assert task.task_id == TASK_ID + assert task.status == PropertyIndexTaskStatus.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 == PropertyIndexTaskStatus.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 == PropertyIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +def test_update_property_index_wait_for_completion( + 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({"taskId": TASK_ID, "status": "STARTED"}, status=202) + 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": "ready", "tokenization": "word"}], + } + ], + } + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + assert status.type == "searchable" + assert status.status == PropertyIndexState.READY + assert status.tokenization == Tokenization.WORD + 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", + ).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 == PropertyIndexTaskStatus.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"}, + ).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 == PropertyIndexTaskStatus.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", + ).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 == PropertyIndexTaskStatus.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", + ).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 == PropertyIndexTaskStatus.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 + assert searchable.type == "searchable" + assert searchable.status == PropertyIndexState.INDEXING + assert searchable.progress == 0.5 + assert searchable.task_id == TASK_ID + assert searchable.tokenization == Tokenization.WORD + assert searchable.target_tokenization == Tokenization.FIELD + assert searchable.algorithm == "wand" + 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].status == PropertyIndexState.READY + assert age.indexes[0].progress is None + assert age.indexes[0].task_id is None + assert age.indexes[0].tokenization is None + + 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() From 6bcb4c5ebbc04668bb6f3b310538e9be349a59b8 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:19:14 +0200 Subject: [PATCH 03/14] fix: address review findings for property reindex API (#2097) - accept a bare string for the tenants argument of update_property_index and rebuild_property_index, normalizing it to a single-element list so it cannot be exploded into a per-character csv (export API precedent) - override to_dict on _PropertyIndexes/_CollectionPropertyIndexes so the nested dataclass lists serialize to JSON-compatible dicts (_CollectionConfig precedent) - drop the dead list branch for dataType in the index status parser - cover the ReindexFailedError/ReindexCanceledError wait paths of both update and rebuild, pin the empty request body of the rebuild/cancel mocks, and prove json.dumps(...to_dict()) round-trips --- mock_tests/test_property_reindex.py | 122 +++++++++++++++++- weaviate/collections/classes/config.py | 10 ++ .../collections/classes/config_methods.py | 17 +-- weaviate/collections/config/async_.pyi | 10 +- weaviate/collections/config/executor.py | 26 ++-- weaviate/collections/config/sync.pyi | 10 +- 6 files changed, 162 insertions(+), 33 deletions(-) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index 84ddd4f3d..225f72263 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -13,7 +13,11 @@ PropertyIndexTaskStatus, Tokenization, ) -from weaviate.exceptions import WeaviateUnsupportedFeatureError +from weaviate.exceptions import ( + ReindexCanceledError, + ReindexFailedError, + WeaviateUnsupportedFeatureError, +) COLLECTION = "TestCollection" SCHEMA_PATH = f"/v1/schema/{COLLECTION}" @@ -128,12 +132,117 @@ def test_update_property_index_wait_for_completion( 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 == PropertyIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_status,exception", + [("failed", ReindexFailedError), ("cancelled", ReindexCanceledError)], +) +def test_update_property_index_wait_raises( + weaviate_139_mock: HTTPServer, + client_139: weaviate.WeaviateClient, + index_status: str, + exception: type, +) -> None: + 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(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( + { + "collection": COLLECTION, + "properties": [ + { + "name": "name", + "dataType": "text", + "indexes": [ + { + "type": "searchable", + "status": index_status, + "progress": 0.42, + "taskId": TASK_ID, + "tokenization": "word", + } + ], + } + ], + } + ) + + with pytest.raises(exception): + client_139.collections.use(COLLECTION).config.update_property_index( + "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + ) + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_status,exception", + [("failed", ReindexFailedError), ("cancelled", ReindexCanceledError)], +) +def test_rebuild_property_index_wait_raises( + weaviate_139_mock: HTTPServer, + client_139: weaviate.WeaviateClient, + index_status: str, + exception: type, +) -> 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) + 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": index_status, + "progress": 0.42, + "taskId": TASK_ID, + "tokenization": "word", + } + ], + } + ], + } + ) + + with pytest.raises(exception): + client_139.collections.use(COLLECTION).config.rebuild_property_index( + "name", "searchable", wait_for_completion=True + ) + 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( @@ -151,6 +260,7 @@ def test_rebuild_property_index_with_tenants( 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( @@ -167,6 +277,7 @@ def test_cancel_property_index_task_cancelled( 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( @@ -183,6 +294,7 @@ def test_cancel_property_index_task_no_op( 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( @@ -270,6 +382,14 @@ def test_get_property_indexes( 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]["status"] == "ready" + weaviate_139_mock.check_assertions() diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 3fdc34d4a..4e462962e 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -2271,6 +2271,11 @@ class _PropertyIndexes(_ConfigBase): description: Optional[str] indexes: List[PropertyIndexStatus] + def to_dict(self) -> Dict[str, Any]: + out = super().to_dict() + out["indexes"] = [index.to_dict() for index in self.indexes] + return out + PropertyIndexes = _PropertyIndexes @@ -2280,6 +2285,11 @@ class _CollectionPropertyIndexes(_ConfigBase): collection: str properties: List[PropertyIndexes] + def to_dict(self) -> Dict[str, Any]: + out = super().to_dict() + out["properties"] = [prop.to_dict() for prop in self.properties] + return out + CollectionPropertyIndexes = _CollectionPropertyIndexes diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index 01981d9af..4ad29beb4 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -592,22 +592,17 @@ def _property_index_status_from_json(index: Dict[str, Any]) -> _PropertyIndexSta def _collection_property_indexes_from_json(response: Dict[str, Any]) -> _CollectionPropertyIndexes: - properties: List[_PropertyIndexes] = [] - for prop in response.get("properties") or []: - data_type = prop.get("dataType") - if isinstance(data_type, list): - data_type = data_type[0] if len(data_type) > 0 else "" - properties.append( + return _CollectionPropertyIndexes( + collection=response["collection"], + properties=[ _PropertyIndexes( name=prop["name"], - data_type=cast(str, data_type), + data_type=prop["dataType"], description=prop.get("description"), indexes=[ _property_index_status_from_json(index) for index in prop.get("indexes") or [] ], ) - ) - return _CollectionPropertyIndexes( - collection=response["collection"], - properties=properties, + for prop in response.get("properties") or [] + ], ) diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 3fe2ef26f..eb2d40dfe 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -1,4 +1,4 @@ -from typing import Dict, List, Literal, Optional, Sequence, Union, overload +from typing import Dict, List, Literal, Optional, Union, overload from typing_extensions import deprecated @@ -102,7 +102,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> PropertyIndexStatus: ... @overload @@ -113,7 +113,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> PropertyIndexTask: ... @overload @@ -122,7 +122,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> PropertyIndexStatus: ... @overload @@ -131,7 +131,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> PropertyIndexTask: ... async def cancel_property_index_task( diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 5c7d35a75..2179e7b49 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -754,7 +754,7 @@ def update_property_index( *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> executor.Result[PropertyIndexStatus]: ... @@ -766,7 +766,7 @@ def update_property_index( *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> executor.Result[PropertyIndexTask]: ... @@ -777,7 +777,7 @@ def update_property_index( *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, ) -> executor.Result[Union[PropertyIndexTask, PropertyIndexStatus]]: """Create or migrate a property index in this collection. @@ -794,9 +794,9 @@ def update_property_index( optional as a change on an existing `searchable` or `filterable` index. Not valid for `rangeFilters`. algorithm: The search algorithm of a `searchable` index. Only `blockmax` may be requested. - tenants: The 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. + 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 wait until the index reports `ready`. By default False. Returns: @@ -823,6 +823,8 @@ def update_property_index( ) if algorithm is not None: body["algorithm"] = algorithm + if isinstance(tenants, str): + tenants = [tenants] params: Optional[Dict[str, Any]] = ( {"tenants": ",".join(tenants)} if tenants is not None else None ) @@ -874,7 +876,7 @@ def rebuild_property_index( property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> executor.Result[PropertyIndexStatus]: ... @@ -884,7 +886,7 @@ def rebuild_property_index( property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> executor.Result[PropertyIndexTask]: ... @@ -893,7 +895,7 @@ def rebuild_property_index( property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, ) -> executor.Result[Union[PropertyIndexTask, PropertyIndexStatus]]: """Rebuild an existing property index from scratch with its current configuration. @@ -901,8 +903,8 @@ def rebuild_property_index( Args: property_name: The property whose index to rebuild. index_name: The type of the index, one of `searchable`, `filterable` or `rangeFilters`. - tenants: The tenants for which to rebuild the index on a multi-tenant collection. - If not provided, all tenants are affected. + 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 wait until the index reports `ready`. By default False. Returns: @@ -922,6 +924,8 @@ def rebuild_property_index( _validate_input([_ValidateArgument(expected=[str], name="index_name", value=index_name)]) path = self.__property_index_path(property_name, index_name) + "/rebuild" + if isinstance(tenants, str): + tenants = [tenants] params: Optional[Dict[str, Any]] = ( {"tenants": ",".join(tenants)} if tenants is not None else None ) diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index 87a87f625..6a25d9001 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -1,4 +1,4 @@ -from typing import Dict, List, Literal, Optional, Sequence, Union, overload +from typing import Dict, List, Literal, Optional, Union, overload from typing_extensions import deprecated @@ -100,7 +100,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> PropertyIndexStatus: ... @overload @@ -111,7 +111,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> PropertyIndexTask: ... @overload @@ -120,7 +120,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> PropertyIndexStatus: ... @overload @@ -129,7 +129,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): property_name: str, index_name: IndexName, *, - tenants: Optional[Sequence[str]] = None, + tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> PropertyIndexTask: ... def cancel_property_index_task( From ce26136e71070f22e213aa988c50b92a071e8ee6 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:39:29 +0200 Subject: [PATCH 04/14] fix: validate reindex arguments before use (#2098 review) Validate the tenants argument of update_property_index and rebuild_property_index as str | List[str] | None before the csv join so invalid input raises WeaviateInvalidInputError instead of a raw TypeError, matching the library's _validate_input idiom already applied to property_name/index_name. Document the WeaviateInvalidInputError in the affected docstrings and cover the validation with mock tests. --- mock_tests/test_property_reindex.py | 15 +++++++++++++++ weaviate/collections/config/executor.py | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index 225f72263..05d6f5e6f 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -393,6 +393,21 @@ def test_get_property_indexes( 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_property_reindex_unsupported_version( weaviate_client: weaviate.WeaviateClient, ) -> None: diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 2179e7b49..f56057e41 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -804,6 +804,7 @@ def update_property_index( 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. @@ -814,6 +815,9 @@ def update_property_index( [_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)] + ) path = self.__property_index_path(property_name, index_name) body: Dict[str, Any] = {} @@ -912,6 +916,7 @@ def rebuild_property_index( 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. @@ -922,6 +927,9 @@ def rebuild_property_index( [_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)] + ) path = self.__property_index_path(property_name, index_name) + "/rebuild" if isinstance(tenants, str): @@ -991,6 +999,7 @@ def cancel_property_index_task( A `PropertyIndexTask` 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. """ From b3bafa2e29bc842bd1f232ae042b7b8651b3fe0d Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:31:19 +0200 Subject: [PATCH 05/14] fix: neutral error message for property index deletion (#2098) The static delete_property_index error prefix asserted a single cause ("Property may not exist") but the DELETE 422 also covers an invalid index type and the in-flight-reindex mutation guard, whose server message the prefix contradicted. Name the failed operation instead and let the appended response body carry the cause, matching the file's phrasing style. Pin the behavior with a mock test surfacing a server-style 422 in-flight-reindex message. --- mock_tests/test_property_reindex.py | 23 +++++++++++++++++++++++ weaviate/collections/config/executor.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index 05d6f5e6f..de38a6cbd 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -393,6 +393,29 @@ def test_get_property_indexes( 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_property_reindex_invalid_input( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index f56057e41..a14b23688 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -704,8 +704,8 @@ def resp(res: Response) -> bool: response_callback=resp, method=self._connection.delete, path=path, - error_msg="Property may not exist", - status_codes=_ExpectedStatusCodes(ok_in=[200], error="property exists"), + 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: From a64ec1507802a5d73cd880054f3591bb095d6032 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:42:25 +0200 Subject: [PATCH 06/14] feat: accept PropertyIndexType enum for index_name arguments (#2098) Adds a PropertyIndexType str-enum (SEARCHABLE, FILTERABLE, RANGE_FILTERS) accepted alongside the IndexName literals on update_property_index, rebuild_property_index, cancel_property_index_task and delete_property_index (backward- compatible widening of the v1.36 signature). The value is normalized to its wire form at the top of each method so paths, params and error messages never render the enum repr. Route-equality mock tests pin that the enum and literal forms hit identical routes, incl. RANGE_FILTERS -> rangeFilters. Read-side status types keep plain literals/str for forward compatibility. --- mock_tests/test_property_reindex.py | 64 ++++++++++++++++++++++++- weaviate/classes/config.py | 2 + weaviate/collections/classes/config.py | 14 ++++++ weaviate/collections/config/async_.pyi | 15 +++--- weaviate/collections/config/executor.py | 34 ++++++++----- weaviate/collections/config/sync.pyi | 15 +++--- 6 files changed, 120 insertions(+), 24 deletions(-) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index de38a6cbd..1dab01ff3 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -1,5 +1,5 @@ import json -from typing import Generator +from typing import Generator, Union import grpc import pytest @@ -11,6 +11,7 @@ from weaviate.collections.classes.config import ( PropertyIndexState, PropertyIndexTaskStatus, + PropertyIndexType, Tokenization, ) from weaviate.exceptions import ( @@ -393,6 +394,67 @@ def test_get_property_indexes( weaviate_139_mock.check_assertions() +@pytest.mark.parametrize( + "index_name,wire", + [ + (PropertyIndexType.SEARCHABLE, "searchable"), + ("searchable", "searchable"), + (PropertyIndexType.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[PropertyIndexType, 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", + index_name, # type: ignore + ) + assert task.status == PropertyIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_name,wire", + [ + (PropertyIndexType.SEARCHABLE, "searchable"), + ("searchable", "searchable"), + (PropertyIndexType.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[PropertyIndexType, 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", + index_name, # type: ignore + ) + is True + ) + weaviate_139_mock.check_assertions() + + def test_delete_property_index_surfaces_server_message( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: diff --git a/weaviate/classes/config.py b/weaviate/classes/config.py index c154062d3..7fdd8c117 100644 --- a/weaviate/classes/config.py +++ b/weaviate/classes/config.py @@ -7,6 +7,7 @@ PQEncoderDistribution, PQEncoderType, Property, + PropertyIndexType, Reconfigure, ReferenceProperty, ReplicationDeletionStrategy, @@ -37,6 +38,7 @@ "MultiVectorAggregation", "ReplicationDeletionStrategy", "Property", + "PropertyIndexType", "PQEncoderDistribution", "PQEncoderType", "ReferenceProperty", diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 4e462962e..7aafa7bc4 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -117,6 +117,20 @@ ] +class PropertyIndexType(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 rangeable 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. diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index eb2d40dfe..baee508e3 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -10,6 +10,7 @@ from weaviate.collections.classes.config import ( Property, PropertyIndexStatus, PropertyIndexTask, + PropertyIndexType, ReferenceProperty, ShardStatus, ShardTypes, @@ -93,12 +94,14 @@ 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[PropertyIndexType, IndexName] + ) -> bool: ... @overload async def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -109,7 +112,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -120,7 +123,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], @@ -129,12 +132,12 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> PropertyIndexTask: ... async def cancel_property_index_task( - self, property_name: str, index_name: IndexName + self, property_name: str, index_name: Union[PropertyIndexType, IndexName] ) -> PropertyIndexTask: ... async def get_property_indexes(self) -> CollectionPropertyIndexes: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index a14b23688..910882def 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -27,6 +27,7 @@ PropertyIndexState, PropertyIndexStatus, PropertyIndexTask, + PropertyIndexType, PropertyType, ReferenceProperty, ShardStatus, @@ -670,7 +671,7 @@ async def _execute() -> None: def delete_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], ) -> executor.Result[bool]: """Delete a property index from the collection in Weaviate. @@ -686,6 +687,8 @@ def delete_property_index( weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. weaviate.exceptions.WeaviateInvalidInputError: If the property or index does not exist. """ + if isinstance(index_name, PropertyIndexType): + index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] ) @@ -750,7 +753,7 @@ async def _execute() -> PropertyIndexStatus: def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -762,7 +765,7 @@ def update_property_index( def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -773,7 +776,7 @@ def update_property_index( def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -789,7 +792,8 @@ def update_property_index( Args: property_name: The property whose index to create or migrate. - index_name: The type of the index, one of `searchable`, `filterable` or `rangeFilters`. + index_name: The type of the index, a `PropertyIndexType` value or one of the literals + `searchable`, `filterable` or `rangeFilters`. 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`. @@ -811,6 +815,8 @@ def update_property_index( weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. """ self.__check_property_reindex_support("Collection config update_property_index") + if isinstance(index_name, PropertyIndexType): + index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] ) @@ -878,7 +884,7 @@ async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], @@ -888,7 +894,7 @@ def rebuild_property_index( def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, @@ -897,7 +903,7 @@ def rebuild_property_index( def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, @@ -906,7 +912,8 @@ def rebuild_property_index( Args: property_name: The property whose index to rebuild. - index_name: The type of the index, one of `searchable`, `filterable` or `rangeFilters`. + index_name: The type of the index, a `PropertyIndexType` value or one of the literals + `searchable`, `filterable` or `rangeFilters`. 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 wait until the index reports `ready`. By default False. @@ -923,6 +930,8 @@ def rebuild_property_index( weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. """ self.__check_property_reindex_support("Collection config rebuild_property_index") + if isinstance(index_name, PropertyIndexType): + index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] ) @@ -982,7 +991,7 @@ async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: def cancel_property_index_task( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], ) -> executor.Result[PropertyIndexTask]: """Cancel the live reindexing task of a property index. @@ -993,7 +1002,8 @@ def cancel_property_index_task( Args: property_name: The property whose reindexing task to cancel. - index_name: The type of the index, one of `searchable`, `filterable` or `rangeFilters`. + index_name: The type of the index, a `PropertyIndexType` value or one of the literals + `searchable`, `filterable` or `rangeFilters`. Returns: A `PropertyIndexTask` with status `CANCELLED` if a live task was cancelled or `NO_OP` otherwise. @@ -1004,6 +1014,8 @@ def cancel_property_index_task( weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. """ self.__check_property_reindex_support("Collection config cancel_property_index_task") + if isinstance(index_name, PropertyIndexType): + index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] ) diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index 6a25d9001..3b95aad2a 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -10,6 +10,7 @@ from weaviate.collections.classes.config import ( Property, PropertyIndexStatus, PropertyIndexTask, + PropertyIndexType, ReferenceProperty, ShardStatus, ShardTypes, @@ -91,12 +92,14 @@ 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[PropertyIndexType, IndexName] + ) -> bool: ... @overload def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -107,7 +110,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def update_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -118,7 +121,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], @@ -127,12 +130,12 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def rebuild_property_index( self, property_name: str, - index_name: IndexName, + index_name: Union[PropertyIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> PropertyIndexTask: ... def cancel_property_index_task( - self, property_name: str, index_name: IndexName + self, property_name: str, index_name: Union[PropertyIndexType, IndexName] ) -> PropertyIndexTask: ... def get_property_indexes(self) -> CollectionPropertyIndexes: ... From 0120c6546d197f40bd79fbb282e0f78640054ada Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:43:25 +0200 Subject: [PATCH 07/14] docs: state the coupled retokenization contract explicitly (#2098) Documents on update_property_index that a tokenization change via the searchable index also retokenizes an existing filterable index as one coupled task (shared taskId) and thereby changes filter matching, with filterable as the target for bucket-only changes and cancellation applying to the whole task. Explains why PropertyIndexes.data_type is a plain str (primitives match the DataType enum, references carry the target collection name) and pins reference-property parsing with a mock test. --- mock_tests/test_property_reindex.py | 38 +++++++++++++++++++++++++ weaviate/collections/classes/config.py | 3 ++ weaviate/collections/config/executor.py | 6 ++++ 3 files changed, 47 insertions(+) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index 1dab01ff3..a5e9f4ece 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -9,6 +9,7 @@ import weaviate from mock_tests.conftest import MOCK_IP, MOCK_PORT, MOCK_PORT_GRPC from weaviate.collections.classes.config import ( + DataType, PropertyIndexState, PropertyIndexTaskStatus, PropertyIndexType, @@ -455,6 +456,43 @@ def test_delete_property_index_enum_and_literal_hit_same_route( 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_delete_property_index_surfaces_server_message( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 7aafa7bc4..6c9c8f0f6 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -2281,6 +2281,9 @@ class _PropertyIndexStatus(_ConfigBase): @dataclass class _PropertyIndexes(_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[PropertyIndexStatus] diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 910882def..13338d3fd 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -790,6 +790,12 @@ def update_property_index( 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, a `PropertyIndexType` value or one of the literals From e65fa0b26e0970dff0eea52c98774c6334def89a Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:52:56 +0200 Subject: [PATCH 08/14] fix: drop internal alias wording, extend route-equality coverage (#2098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RANGE_FILTERS docstring said "rangeable", the internal write-path alias the RFC deliberately keeps unsurfaced — say rangeFilters instead. Extends the route-equality parametrization with FILTERABLE and adds enum-vs-literal route cases for rebuild_property_index and cancel_property_index_task. --- mock_tests/test_property_reindex.py | 52 ++++++++++++++++++++++++++ weaviate/collections/classes/config.py | 2 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index a5e9f4ece..8ebb373b6 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -400,6 +400,8 @@ def test_get_property_indexes( [ (PropertyIndexType.SEARCHABLE, "searchable"), ("searchable", "searchable"), + (PropertyIndexType.FILTERABLE, "filterable"), + ("filterable", "filterable"), (PropertyIndexType.RANGE_FILTERS, "rangeFilters"), ("rangeFilters", "rangeFilters"), ], @@ -430,6 +432,8 @@ def test_update_property_index_enum_and_literal_hit_same_route( [ (PropertyIndexType.SEARCHABLE, "searchable"), ("searchable", "searchable"), + (PropertyIndexType.FILTERABLE, "filterable"), + ("filterable", "filterable"), (PropertyIndexType.RANGE_FILTERS, "rangeFilters"), ("rangeFilters", "rangeFilters"), ], @@ -456,6 +460,54 @@ def test_delete_property_index_enum_and_literal_hit_same_route( weaviate_139_mock.check_assertions() +@pytest.mark.parametrize( + "index_name", + [PropertyIndexType.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[PropertyIndexType, 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", + index_name, # type: ignore + ) + assert task.status == PropertyIndexTaskStatus.STARTED + weaviate_139_mock.check_assertions() + + +@pytest.mark.parametrize( + "index_name", + [PropertyIndexType.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[PropertyIndexType, 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", + index_name, # type: ignore + ) + assert task.status == PropertyIndexTaskStatus.CANCELLED + weaviate_139_mock.check_assertions() + + def test_get_property_indexes_reference_property( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 6c9c8f0f6..8ae165140 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -123,7 +123,7 @@ class PropertyIndexType(str, BaseEnum): 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 rangeable index, used for range filtering. + RANGE_FILTERS: The rangeFilters index, used for range filtering. """ SEARCHABLE = "searchable" From d4a4ef48eb3c2cc5d0de401714ef86d7a3bf76f5 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:28:05 +0200 Subject: [PATCH 09/14] refactor: scope reindex type names to inverted indexes (#2098) Renames the unreleased public types PropertyIndexType/State/Status/ Task/TaskStatus to InvertedIndexType/State/Status/Task/TaskStatus and PropertyIndexes/CollectionPropertyIndexes to PropertyInvertedIndexes/ CollectionInvertedIndexes, incl. private counterparts and parser names. The runtime reindex API only ever touches inverted indexes; a future vector reindex must not collide with these names. Method names, the released IndexName alias and the deliberately generic Reindex*Error exceptions are unchanged. --- integration/test_collection_config.py | 36 +++--- mock_tests/test_property_reindex.py | 58 +++++----- weaviate/classes/config.py | 4 +- weaviate/collections/classes/config.py | 30 ++--- .../collections/classes/config_methods.py | 32 +++--- weaviate/collections/config/async_.pyi | 32 +++--- weaviate/collections/config/executor.py | 106 +++++++++--------- weaviate/collections/config/sync.pyi | 32 +++--- weaviate/outputs/config.py | 24 ++-- 9 files changed, 177 insertions(+), 177 deletions(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index fc9bcb4cf..7764b7c38 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -41,8 +41,8 @@ _NamedVectorConfigCreate, _VectorizerConfigCreate, IndexName, - PropertyIndexState, - PropertyIndexTaskStatus, + InvertedIndexState, + InvertedIndexTaskStatus, ) from weaviate.collections.classes.tenants import Tenant from weaviate.exceptions import ( @@ -2706,14 +2706,14 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True ) assert status.type == "searchable" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY assert status.tokenization == Tokenization.WORD # re-putting the matching configuration is a no-op task = collection.config.update_property_index( "name", "searchable", tokenization=Tokenization.WORD ) - assert task.status == PropertyIndexTaskStatus.NO_OP + assert task.status == InvertedIndexTaskStatus.NO_OP assert task.task_id is None # the status endpoint reports the index as ready @@ -2726,18 +2726,18 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac for index in prop.indexes if index.type == "searchable" ) - assert entry.status == PropertyIndexState.READY + assert entry.status == InvertedIndexState.READY # rebuild the index from scratch status = collection.config.rebuild_property_index( "name", "searchable", wait_for_completion=True ) assert status.type == "searchable" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY # cancelling when no task is live is an idempotent no-op task = collection.config.cancel_property_index_task("name", "searchable") - assert task.status == PropertyIndexTaskStatus.NO_OP + 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 @@ -2765,7 +2765,7 @@ def test_property_reindex_range_filters(collection_factory: CollectionFactory) - "age", "rangeFilters", wait_for_completion=True ) assert status.type == "rangeFilters" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY entry = next( index @@ -2774,7 +2774,7 @@ def test_property_reindex_range_filters(collection_factory: CollectionFactory) - for index in prop.indexes if index.type == "rangeFilters" ) - assert entry.status == PropertyIndexState.READY + assert entry.status == InvertedIndexState.READY def test_property_reindex_coupled_tokenization_change( @@ -2801,7 +2801,7 @@ def test_property_reindex_coupled_tokenization_change( task = collection.config.update_property_index( "name", "searchable", tokenization=Tokenization.FIELD ) - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED assert task.task_id is not None prop = next(p for p in collection.config.get_property_indexes().properties if p.name == "name") @@ -2821,12 +2821,12 @@ def test_property_reindex_coupled_tokenization_change( status = collection.config.update_property_index( "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True ) - assert status.status == PropertyIndexState.READY + assert status.status == 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.status == PropertyIndexState.READY + assert filterable.status == InvertedIndexState.READY assert filterable.tokenization == Tokenization.FIELD @@ -2854,13 +2854,13 @@ def test_property_reindex_multi_tenant(collection_factory: CollectionFactory) -> "age", "rangeFilters", tenants=["tenant1", "tenant2"], wait_for_completion=True ) assert status.type == "rangeFilters" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY status = collection.config.rebuild_property_index( "age", "rangeFilters", tenants=["tenant1"], wait_for_completion=True ) assert status.type == "rangeFilters" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY @pytest.mark.asyncio @@ -2885,12 +2885,12 @@ async def test_property_reindex_async(async_collection_factory: AsyncCollectionF "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True ) assert status.type == "searchable" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY task = await collection.config.update_property_index( "name", "searchable", tokenization=Tokenization.WORD ) - assert task.status == PropertyIndexTaskStatus.NO_OP + assert task.status == InvertedIndexTaskStatus.NO_OP indexes = await collection.config.get_property_indexes() assert indexes.collection == collection.name @@ -2898,7 +2898,7 @@ async def test_property_reindex_async(async_collection_factory: AsyncCollectionF status = await collection.config.rebuild_property_index( "name", "searchable", wait_for_completion=True ) - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY task = await collection.config.cancel_property_index_task("name", "searchable") - assert task.status == PropertyIndexTaskStatus.NO_OP + assert task.status == InvertedIndexTaskStatus.NO_OP diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index 8ebb373b6..2a21c8128 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -10,9 +10,9 @@ from mock_tests.conftest import MOCK_IP, MOCK_PORT, MOCK_PORT_GRPC from weaviate.collections.classes.config import ( DataType, - PropertyIndexState, - PropertyIndexTaskStatus, - PropertyIndexType, + InvertedIndexState, + InvertedIndexTaskStatus, + InvertedIndexType, Tokenization, ) from weaviate.exceptions import ( @@ -64,7 +64,7 @@ def test_update_property_index_started( algorithm="blockmax", ) assert task.task_id == TASK_ID - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @@ -81,7 +81,7 @@ def test_update_property_index_no_op( "name", "searchable", tokenization=Tokenization.WORD ) assert task.task_id is None - assert task.status == PropertyIndexTaskStatus.NO_OP + assert task.status == InvertedIndexTaskStatus.NO_OP weaviate_139_mock.check_assertions() @@ -100,7 +100,7 @@ def test_update_property_index_range_filters_with_tenants( "age", "rangeFilters", tenants=["tenant1", "tenant2"] ) assert task.task_id == TASK_ID - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @@ -129,7 +129,7 @@ def test_update_property_index_wait_for_completion( "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True ) assert status.type == "searchable" - assert status.status == PropertyIndexState.READY + assert status.status == InvertedIndexState.READY assert status.tokenization == Tokenization.WORD weaviate_139_mock.check_assertions() @@ -148,7 +148,7 @@ def test_update_property_index_bare_str_tenant( task = client_139.collections.use(COLLECTION).config.update_property_index( "age", "rangeFilters", tenants="tenant1" ) - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @@ -251,7 +251,7 @@ def test_rebuild_property_index( "name", "searchable" ) assert task.task_id == TASK_ID - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @@ -269,7 +269,7 @@ def test_rebuild_property_index_with_tenants( "age", "rangeFilters", tenants=["tenant1", "tenant2"] ) assert task.task_id == TASK_ID - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @@ -286,7 +286,7 @@ def test_cancel_property_index_task_cancelled( "name", "searchable" ) assert task.task_id == TASK_ID - assert task.status == PropertyIndexTaskStatus.CANCELLED + assert task.status == InvertedIndexTaskStatus.CANCELLED weaviate_139_mock.check_assertions() @@ -303,7 +303,7 @@ def test_cancel_property_index_task_no_op( "name", "searchable" ) assert task.task_id is None - assert task.status == PropertyIndexTaskStatus.NO_OP + assert task.status == InvertedIndexTaskStatus.NO_OP weaviate_139_mock.check_assertions() @@ -360,7 +360,7 @@ def test_get_property_indexes( assert len(name.indexes) == 2 searchable, filterable = name.indexes assert searchable.type == "searchable" - assert searchable.status == PropertyIndexState.INDEXING + assert searchable.status == InvertedIndexState.INDEXING assert searchable.progress == 0.5 assert searchable.task_id == TASK_ID assert searchable.tokenization == Tokenization.WORD @@ -379,7 +379,7 @@ def test_get_property_indexes( assert age.description is None assert len(age.indexes) == 1 assert age.indexes[0].type == "rangeFilters" - assert age.indexes[0].status == PropertyIndexState.READY + assert age.indexes[0].status == InvertedIndexState.READY assert age.indexes[0].progress is None assert age.indexes[0].task_id is None assert age.indexes[0].tokenization is None @@ -398,18 +398,18 @@ def test_get_property_indexes( @pytest.mark.parametrize( "index_name,wire", [ - (PropertyIndexType.SEARCHABLE, "searchable"), + (InvertedIndexType.SEARCHABLE, "searchable"), ("searchable", "searchable"), - (PropertyIndexType.FILTERABLE, "filterable"), + (InvertedIndexType.FILTERABLE, "filterable"), ("filterable", "filterable"), - (PropertyIndexType.RANGE_FILTERS, "rangeFilters"), + (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[PropertyIndexType, str], + index_name: Union[InvertedIndexType, str], wire: str, ) -> None: """The enum and literal forms of index_name hit the exact same wire route.""" @@ -423,25 +423,25 @@ def test_update_property_index_enum_and_literal_hit_same_route( "name", index_name, # type: ignore ) - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @pytest.mark.parametrize( "index_name,wire", [ - (PropertyIndexType.SEARCHABLE, "searchable"), + (InvertedIndexType.SEARCHABLE, "searchable"), ("searchable", "searchable"), - (PropertyIndexType.FILTERABLE, "filterable"), + (InvertedIndexType.FILTERABLE, "filterable"), ("filterable", "filterable"), - (PropertyIndexType.RANGE_FILTERS, "rangeFilters"), + (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[PropertyIndexType, str], + index_name: Union[InvertedIndexType, str], wire: str, ) -> None: """The enum and literal forms of index_name hit the exact same wire route.""" @@ -462,12 +462,12 @@ def test_delete_property_index_enum_and_literal_hit_same_route( @pytest.mark.parametrize( "index_name", - [PropertyIndexType.RANGE_FILTERS, "rangeFilters"], + [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[PropertyIndexType, str], + 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( @@ -480,18 +480,18 @@ def test_rebuild_property_index_enum_and_literal_hit_same_route( "age", index_name, # type: ignore ) - assert task.status == PropertyIndexTaskStatus.STARTED + assert task.status == InvertedIndexTaskStatus.STARTED weaviate_139_mock.check_assertions() @pytest.mark.parametrize( "index_name", - [PropertyIndexType.RANGE_FILTERS, "rangeFilters"], + [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[PropertyIndexType, str], + 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( @@ -504,7 +504,7 @@ def test_cancel_property_index_task_enum_and_literal_hit_same_route( "age", index_name, # type: ignore ) - assert task.status == PropertyIndexTaskStatus.CANCELLED + assert task.status == InvertedIndexTaskStatus.CANCELLED weaviate_139_mock.check_assertions() diff --git a/weaviate/classes/config.py b/weaviate/classes/config.py index 7fdd8c117..a806aa04a 100644 --- a/weaviate/classes/config.py +++ b/weaviate/classes/config.py @@ -4,10 +4,10 @@ DataType, GenerativeSearches, IndexName, + InvertedIndexType, PQEncoderDistribution, PQEncoderType, Property, - PropertyIndexType, Reconfigure, ReferenceProperty, ReplicationDeletionStrategy, @@ -38,7 +38,7 @@ "MultiVectorAggregation", "ReplicationDeletionStrategy", "Property", - "PropertyIndexType", + "InvertedIndexType", "PQEncoderDistribution", "PQEncoderType", "ReferenceProperty", diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 8ae165140..9026dd693 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -117,7 +117,7 @@ ] -class PropertyIndexType(str, BaseEnum): +class InvertedIndexType(str, BaseEnum): """The available property index types in Weaviate. Attributes: @@ -2221,7 +2221,7 @@ class _ShardStatus: ShardStatus = _ShardStatus -class PropertyIndexTaskStatus(str, BaseEnum): +class InvertedIndexTaskStatus(str, BaseEnum): """The status of a runtime property index task submission. Attributes: @@ -2236,7 +2236,7 @@ class PropertyIndexTaskStatus(str, BaseEnum): NO_OP = "NO_OP" -class PropertyIndexState(str, BaseEnum): +class InvertedIndexState(str, BaseEnum): """The state of a property index as reported by the index status endpoint. Attributes: @@ -2255,18 +2255,18 @@ class PropertyIndexState(str, BaseEnum): @dataclass -class _PropertyIndexTask(_ConfigBase): +class _InvertedIndexTask(_ConfigBase): task_id: Optional[str] - status: PropertyIndexTaskStatus + status: InvertedIndexTaskStatus -PropertyIndexTask = _PropertyIndexTask +InvertedIndexTask = _InvertedIndexTask @dataclass -class _PropertyIndexStatus(_ConfigBase): +class _InvertedIndexStatus(_ConfigBase): type: IndexName # noqa: A003 - status: PropertyIndexState + status: InvertedIndexState progress: Optional[float] task_id: Optional[str] tokenization: Optional[Tokenization] @@ -2275,18 +2275,18 @@ class _PropertyIndexStatus(_ConfigBase): target_algorithm: Optional[str] -PropertyIndexStatus = _PropertyIndexStatus +InvertedIndexStatus = _InvertedIndexStatus @dataclass -class _PropertyIndexes(_ConfigBase): +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[PropertyIndexStatus] + indexes: List[InvertedIndexStatus] def to_dict(self) -> Dict[str, Any]: out = super().to_dict() @@ -2294,13 +2294,13 @@ def to_dict(self) -> Dict[str, Any]: return out -PropertyIndexes = _PropertyIndexes +PropertyInvertedIndexes = _PropertyInvertedIndexes @dataclass -class _CollectionPropertyIndexes(_ConfigBase): +class _CollectionInvertedIndexes(_ConfigBase): collection: str - properties: List[PropertyIndexes] + properties: List[PropertyInvertedIndexes] def to_dict(self) -> Dict[str, Any]: out = super().to_dict() @@ -2308,7 +2308,7 @@ def to_dict(self) -> Dict[str, Any]: return out -CollectionPropertyIndexes = _CollectionPropertyIndexes +CollectionInvertedIndexes = _CollectionInvertedIndexes class _TextAnalyzerConfigCreate(_ConfigCreateModel): diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index 4ad29beb4..dbb7762d2 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -5,10 +5,10 @@ DataType, GenerativeSearches, IndexName, + InvertedIndexState, + InvertedIndexTaskStatus, PQEncoderDistribution, PQEncoderType, - PropertyIndexState, - PropertyIndexTaskStatus, ReplicationDeletionStrategy, Rerankers, StopwordsPreset, @@ -22,9 +22,11 @@ _BQConfig, _CollectionConfig, _CollectionConfigSimple, - _CollectionPropertyIndexes, + _CollectionInvertedIndexes, _GenerativeConfig, _InvertedIndexConfig, + _InvertedIndexStatus, + _InvertedIndexTask, _MultiTenancyConfig, _MultiVectorConfig, _MuveraConfig, @@ -35,9 +37,7 @@ _PQConfig, _PQEncoderConfig, _Property, - _PropertyIndexes, - _PropertyIndexStatus, - _PropertyIndexTask, + _PropertyInvertedIndexes, _PropertyVectorizerConfig, _ReferenceProperty, _ReplicationConfig, @@ -567,19 +567,19 @@ def _references_from_config(schema: Dict[str, Any]) -> List[_ReferenceProperty]: ] -def _property_index_task_from_json(response: Dict[str, Any]) -> _PropertyIndexTask: - return _PropertyIndexTask( +def _inverted_index_task_from_json(response: Dict[str, Any]) -> _InvertedIndexTask: + return _InvertedIndexTask( task_id=response.get("taskId"), - status=PropertyIndexTaskStatus(response["status"]), + status=InvertedIndexTaskStatus(response["status"]), ) -def _property_index_status_from_json(index: Dict[str, Any]) -> _PropertyIndexStatus: +def _inverted_index_status_from_json(index: Dict[str, Any]) -> _InvertedIndexStatus: tokenization = index.get("tokenization") target_tokenization = index.get("targetTokenization") - return _PropertyIndexStatus( + return _InvertedIndexStatus( type=cast(IndexName, index["type"]), - status=PropertyIndexState(index["status"]), + status=InvertedIndexState(index["status"]), progress=index.get("progress"), task_id=index.get("taskId"), tokenization=Tokenization(tokenization) if tokenization is not None else None, @@ -591,16 +591,16 @@ def _property_index_status_from_json(index: Dict[str, Any]) -> _PropertyIndexSta ) -def _collection_property_indexes_from_json(response: Dict[str, Any]) -> _CollectionPropertyIndexes: - return _CollectionPropertyIndexes( +def _collection_inverted_indexes_from_json(response: Dict[str, Any]) -> _CollectionInvertedIndexes: + return _CollectionInvertedIndexes( collection=response["collection"], properties=[ - _PropertyIndexes( + _PropertyInvertedIndexes( name=prop["name"], data_type=prop["dataType"], description=prop.get("description"), indexes=[ - _property_index_status_from_json(index) for index in prop.get("indexes") or [] + _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 baee508e3..7041b4e24 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -5,12 +5,12 @@ from typing_extensions import deprecated from weaviate.collections.classes.config import ( CollectionConfig, CollectionConfigSimple, - CollectionPropertyIndexes, + CollectionInvertedIndexes, IndexName, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexType, Property, - PropertyIndexStatus, - PropertyIndexTask, - PropertyIndexType, ReferenceProperty, ShardStatus, ShardTypes, @@ -95,49 +95,49 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... async def delete_property_index( - self, property_name: str, index_name: Union[PropertyIndexType, IndexName] + self, property_name: str, index_name: Union[InvertedIndexType, IndexName] ) -> bool: ... @overload async def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], - ) -> PropertyIndexStatus: ... + ) -> InvertedIndexStatus: ... @overload async def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, - ) -> PropertyIndexTask: ... + ) -> InvertedIndexTask: ... @overload async def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], - ) -> PropertyIndexStatus: ... + ) -> InvertedIndexStatus: ... @overload async def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, - ) -> PropertyIndexTask: ... + ) -> InvertedIndexTask: ... async def cancel_property_index_task( - self, property_name: str, index_name: Union[PropertyIndexType, IndexName] - ) -> PropertyIndexTask: ... - async def get_property_indexes(self) -> CollectionPropertyIndexes: ... + self, property_name: str, index_name: Union[InvertedIndexType, IndexName] + ) -> InvertedIndexTask: ... + async def get_property_indexes(self) -> CollectionInvertedIndexes: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 13338d3fd..0b855a22e 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -21,13 +21,13 @@ from weaviate.collections.classes.config import ( CollectionConfig, CollectionConfigSimple, - CollectionPropertyIndexes, + CollectionInvertedIndexes, IndexName, + InvertedIndexState, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexType, Property, - PropertyIndexState, - PropertyIndexStatus, - PropertyIndexTask, - PropertyIndexType, PropertyType, ReferenceProperty, ShardStatus, @@ -52,8 +52,8 @@ from weaviate.collections.classes.config_methods import ( _collection_config_from_json, _collection_config_simple_from_json, - _collection_property_indexes_from_json, - _property_index_task_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 ( @@ -91,8 +91,8 @@ def _property_has_text_analyzer(prop: Property) -> bool: def _find_property_index_status( - indexes: CollectionPropertyIndexes, property_name: str, index_name: IndexName -) -> Optional[PropertyIndexStatus]: + indexes: CollectionInvertedIndexes, property_name: str, index_name: IndexName +) -> Optional[InvertedIndexStatus]: for prop in indexes.properties: if prop.name != property_name: continue @@ -103,18 +103,18 @@ def _find_property_index_status( def _terminal_property_index_status( - entry: Optional[PropertyIndexStatus], property_name: str, index_name: IndexName -) -> Optional[PropertyIndexStatus]: + entry: Optional[InvertedIndexStatus], property_name: str, index_name: IndexName +) -> Optional[InvertedIndexStatus]: """Return the entry once it is ready, raise on failure/cancellation, or return None to keep polling.""" if entry is None: return None - if entry.status == PropertyIndexState.READY: + if entry.status == InvertedIndexState.READY: return entry - if entry.status == PropertyIndexState.FAILED: + if entry.status == InvertedIndexState.FAILED: raise ReindexFailedError( f"Reindexing the '{index_name}' index of property '{property_name}' failed." ) - if entry.status == PropertyIndexState.CANCELLED: + if entry.status == InvertedIndexState.CANCELLED: raise ReindexCanceledError( f"Reindexing the '{index_name}' index of property '{property_name}' was cancelled." ) @@ -671,7 +671,7 @@ async def _execute() -> None: def delete_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], ) -> executor.Result[bool]: """Delete a property index from the collection in Weaviate. @@ -687,7 +687,7 @@ def delete_property_index( weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. weaviate.exceptions.WeaviateInvalidInputError: If the property or index does not exist. """ - if isinstance(index_name, PropertyIndexType): + if isinstance(index_name, InvertedIndexType): index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] @@ -728,10 +728,10 @@ def __property_index_path(self, property_name: str, index_name: IndexName) -> st def __wait_for_property_index( self, property_name: str, index_name: IndexName - ) -> executor.Result[PropertyIndexStatus]: + ) -> executor.Result[InvertedIndexStatus]: if isinstance(self._connection, ConnectionAsync): - async def _execute() -> PropertyIndexStatus: + async def _execute() -> InvertedIndexStatus: while True: indexes = await executor.aresult(self.get_property_indexes()) entry = _find_property_index_status(indexes, property_name, index_name) @@ -753,36 +753,36 @@ async def _execute() -> PropertyIndexStatus: def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], - ) -> executor.Result[PropertyIndexStatus]: ... + ) -> executor.Result[InvertedIndexStatus]: ... @overload def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, - ) -> executor.Result[PropertyIndexTask]: ... + ) -> executor.Result[InvertedIndexTask]: ... def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, - ) -> executor.Result[Union[PropertyIndexTask, PropertyIndexStatus]]: + ) -> 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 @@ -798,7 +798,7 @@ def update_property_index( Args: property_name: The property whose index to create or migrate. - index_name: The type of the index, a `PropertyIndexType` value or one of the literals + index_name: The type of the index, a `InvertedIndexType` value or one of the literals `searchable`, `filterable` or `rangeFilters`. 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 @@ -810,7 +810,7 @@ def update_property_index( wait_for_completion: Whether to wait until the index reports `ready`. By default False. Returns: - A `PropertyIndexTask` when `wait_for_completion=False`, or the final `PropertyIndexStatus` + A `InvertedIndexTask` when `wait_for_completion=False`, or the final `InvertedIndexStatus` of the index when `wait_for_completion=True`. Raises: @@ -821,7 +821,7 @@ def update_property_index( weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. """ self.__check_property_reindex_support("Collection config update_property_index") - if isinstance(index_name, PropertyIndexType): + if isinstance(index_name, InvertedIndexType): index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] @@ -845,14 +845,14 @@ def update_property_index( {"tenants": ",".join(tenants)} if tenants is not None else None ) - def resp(res: Response) -> PropertyIndexTask: + def resp(res: Response) -> InvertedIndexTask: response = _decode_json_response_dict(res, "Update property index") assert response is not None - return _property_index_task_from_json(response) + return _inverted_index_task_from_json(response) if isinstance(self._connection, ConnectionAsync): - async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: + async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: res = await executor.aresult( self._connection.put( path=path, @@ -890,42 +890,42 @@ async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], - ) -> executor.Result[PropertyIndexStatus]: ... + ) -> executor.Result[InvertedIndexStatus]: ... @overload def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, - ) -> executor.Result[PropertyIndexTask]: ... + ) -> executor.Result[InvertedIndexTask]: ... def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, - ) -> executor.Result[Union[PropertyIndexTask, PropertyIndexStatus]]: + ) -> 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, a `PropertyIndexType` value or one of the literals + index_name: The type of the index, a `InvertedIndexType` value or one of the literals `searchable`, `filterable` or `rangeFilters`. 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 wait until the index reports `ready`. By default False. Returns: - A `PropertyIndexTask` when `wait_for_completion=False`, or the final `PropertyIndexStatus` + A `InvertedIndexTask` when `wait_for_completion=False`, or the final `InvertedIndexStatus` of the index when `wait_for_completion=True`. Raises: @@ -936,7 +936,7 @@ def rebuild_property_index( weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. """ self.__check_property_reindex_support("Collection config rebuild_property_index") - if isinstance(index_name, PropertyIndexType): + if isinstance(index_name, InvertedIndexType): index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] @@ -953,14 +953,14 @@ def rebuild_property_index( {"tenants": ",".join(tenants)} if tenants is not None else None ) - def resp(res: Response) -> PropertyIndexTask: + def resp(res: Response) -> InvertedIndexTask: response = _decode_json_response_dict(res, "Rebuild property index") assert response is not None - return _property_index_task_from_json(response) + return _inverted_index_task_from_json(response) if isinstance(self._connection, ConnectionAsync): - async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: + async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: res = await executor.aresult( self._connection.post( path=path, @@ -997,8 +997,8 @@ async def _execute() -> Union[PropertyIndexTask, PropertyIndexStatus]: def cancel_property_index_task( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], - ) -> executor.Result[PropertyIndexTask]: + index_name: Union[InvertedIndexType, IndexName], + ) -> 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 @@ -1008,11 +1008,11 @@ def cancel_property_index_task( Args: property_name: The property whose reindexing task to cancel. - index_name: The type of the index, a `PropertyIndexType` value or one of the literals + index_name: The type of the index, a `InvertedIndexType` value or one of the literals `searchable`, `filterable` or `rangeFilters`. Returns: - A `PropertyIndexTask` with status `CANCELLED` if a live task was cancelled or `NO_OP` otherwise. + 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. @@ -1020,7 +1020,7 @@ def cancel_property_index_task( weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. """ self.__check_property_reindex_support("Collection config cancel_property_index_task") - if isinstance(index_name, PropertyIndexType): + if isinstance(index_name, InvertedIndexType): index_name = cast(IndexName, index_name.value) _validate_input( [_ValidateArgument(expected=[str], name="property_name", value=property_name)] @@ -1029,10 +1029,10 @@ def cancel_property_index_task( path = self.__property_index_path(property_name, index_name) + "/cancel" - def resp(res: Response) -> PropertyIndexTask: + def resp(res: Response) -> InvertedIndexTask: response = _decode_json_response_dict(res, "Cancel property index task") assert response is not None - return _property_index_task_from_json(response) + return _inverted_index_task_from_json(response) return executor.execute( response_callback=resp, @@ -1043,7 +1043,7 @@ def resp(res: Response) -> PropertyIndexTask: status_codes=_ExpectedStatusCodes(ok_in=[202], error="Cancel property index task"), ) - def get_property_indexes(self) -> executor.Result[CollectionPropertyIndexes]: + 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 @@ -1051,7 +1051,7 @@ def get_property_indexes(self) -> executor.Result[CollectionPropertyIndexes]: reindexing task. Returns: - A `CollectionPropertyIndexes` object containing the index statuses grouped by property. + A `CollectionInvertedIndexes` object containing the index statuses grouped by property. Raises: weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. @@ -1059,10 +1059,10 @@ def get_property_indexes(self) -> executor.Result[CollectionPropertyIndexes]: """ self.__check_property_reindex_support("Collection config get_property_indexes") - def resp(res: Response) -> CollectionPropertyIndexes: + def resp(res: Response) -> CollectionInvertedIndexes: response = _decode_json_response_dict(res, "Get property indexes") assert response is not None - return _collection_property_indexes_from_json(response) + return _collection_inverted_indexes_from_json(response) return executor.execute( response_callback=resp, diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index 3b95aad2a..869d51f78 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -5,12 +5,12 @@ from typing_extensions import deprecated from weaviate.collections.classes.config import ( CollectionConfig, CollectionConfigSimple, - CollectionPropertyIndexes, + CollectionInvertedIndexes, IndexName, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexType, Property, - PropertyIndexStatus, - PropertyIndexTask, - PropertyIndexType, ReferenceProperty, ShardStatus, ShardTypes, @@ -93,49 +93,49 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... def delete_property_index( - self, property_name: str, index_name: Union[PropertyIndexType, IndexName] + self, property_name: str, index_name: Union[InvertedIndexType, IndexName] ) -> bool: ... @overload def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], - ) -> PropertyIndexStatus: ... + ) -> InvertedIndexStatus: ... @overload def update_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, - ) -> PropertyIndexTask: ... + ) -> InvertedIndexTask: ... @overload def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], - ) -> PropertyIndexStatus: ... + ) -> InvertedIndexStatus: ... @overload def rebuild_property_index( self, property_name: str, - index_name: Union[PropertyIndexType, IndexName], + index_name: Union[InvertedIndexType, IndexName], *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, - ) -> PropertyIndexTask: ... + ) -> InvertedIndexTask: ... def cancel_property_index_task( - self, property_name: str, index_name: Union[PropertyIndexType, IndexName] - ) -> PropertyIndexTask: ... - def get_property_indexes(self) -> CollectionPropertyIndexes: ... + self, property_name: str, index_name: Union[InvertedIndexType, IndexName] + ) -> InvertedIndexTask: ... + def get_property_indexes(self) -> CollectionInvertedIndexes: ... diff --git a/weaviate/outputs/config.py b/weaviate/outputs/config.py index b708d0a63..7f156ce50 100644 --- a/weaviate/outputs/config.py +++ b/weaviate/outputs/config.py @@ -3,21 +3,21 @@ BM25Config, CollectionConfig, CollectionConfigSimple, - CollectionPropertyIndexes, + CollectionInvertedIndexes, GenerativeConfig, GenerativeSearches, InvertedIndexConfig, + InvertedIndexState, + InvertedIndexStatus, + InvertedIndexTask, + InvertedIndexTaskStatus, MultiTenancyConfig, PQConfig, PQEncoderConfig, PQEncoderDistribution, PQEncoderType, PropertyConfig, - PropertyIndexes, - PropertyIndexState, - PropertyIndexStatus, - PropertyIndexTask, - PropertyIndexTaskStatus, + PropertyInvertedIndexes, PropertyType, ReferencePropertyConfig, ReplicationConfig, @@ -41,7 +41,7 @@ "BM25Config", "CollectionConfig", "CollectionConfigSimple", - "CollectionPropertyIndexes", + "CollectionInvertedIndexes", "GenerativeConfig", "GenerativeSearches", "InvertedIndexConfig", @@ -52,11 +52,11 @@ "PQEncoderDistribution", "PQEncoderType", "PropertyConfig", - "PropertyIndexes", - "PropertyIndexState", - "PropertyIndexStatus", - "PropertyIndexTask", - "PropertyIndexTaskStatus", + "PropertyInvertedIndexes", + "InvertedIndexState", + "InvertedIndexStatus", + "InvertedIndexTask", + "InvertedIndexTaskStatus", "PropertyType", "ReferencePropertyConfig", "ReplicationConfig", From fbe4a6320790eef8e028dd11e602bfd6a392cac6 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:42:01 +0200 Subject: [PATCH 10/14] refactor: require InvertedIndexType on the new reindex methods (#2098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens index_name to InvertedIndexType only on update_property_index, rebuild_property_index and cancel_property_index_task (all overloads and impls); delete_property_index keeps accepting the IndexName literals as released v1.36 API. Runtime leniency is preserved — the value is still normalized and validated as a str, so raw strings keep working and keep hitting the same routes (pinned by the literal legs of the route-equality mock tests, which sit outside the pyright scope). --- integration/test_collection_config.py | 39 ++++++++++----- mock_tests/test_property_reindex.py | 4 ++ weaviate/collections/config/async_.pyi | 10 ++-- weaviate/collections/config/executor.py | 64 +++++++++++++------------ weaviate/collections/config/sync.pyi | 10 ++-- 5 files changed, 74 insertions(+), 53 deletions(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index 7764b7c38..542eeaa1d 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -43,6 +43,7 @@ IndexName, InvertedIndexState, InvertedIndexTaskStatus, + InvertedIndexType, ) from weaviate.collections.classes.tenants import Tenant from weaviate.exceptions import ( @@ -2703,7 +2704,10 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac # create the searchable index declaratively and wait for it to become ready status = collection.config.update_property_index( - "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.WORD, + wait_for_completion=True, ) assert status.type == "searchable" assert status.status == InvertedIndexState.READY @@ -2711,7 +2715,7 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac # re-putting the matching configuration is a no-op task = collection.config.update_property_index( - "name", "searchable", tokenization=Tokenization.WORD + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.WORD ) assert task.status == InvertedIndexTaskStatus.NO_OP assert task.task_id is None @@ -2730,13 +2734,13 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac # rebuild the index from scratch status = collection.config.rebuild_property_index( - "name", "searchable", wait_for_completion=True + "name", InvertedIndexType.SEARCHABLE, wait_for_completion=True ) assert status.type == "searchable" assert status.status == InvertedIndexState.READY # cancelling when no task is live is an idempotent no-op - task = collection.config.cancel_property_index_task("name", "searchable") + 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 @@ -2762,7 +2766,7 @@ def test_property_reindex_range_filters(collection_factory: CollectionFactory) - collection.data.insert_many([{"age": i} for i in range(10)]) status = collection.config.update_property_index( - "age", "rangeFilters", wait_for_completion=True + "age", InvertedIndexType.RANGE_FILTERS, wait_for_completion=True ) assert status.type == "rangeFilters" assert status.status == InvertedIndexState.READY @@ -2799,7 +2803,7 @@ def test_property_reindex_coupled_tokenization_change( collection.data.insert_many([{"name": f"object {i}"} for i in range(100)]) task = collection.config.update_property_index( - "name", "searchable", tokenization=Tokenization.FIELD + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.FIELD ) assert task.status == InvertedIndexTaskStatus.STARTED assert task.task_id is not None @@ -2819,7 +2823,10 @@ def test_property_reindex_coupled_tokenization_change( # poll the status endpoint (via the wait path of a NO_OP upsert) until the migration is done status = collection.config.update_property_index( - "name", "searchable", tokenization=Tokenization.FIELD, wait_for_completion=True + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.FIELD, + wait_for_completion=True, ) assert status.status == InvertedIndexState.READY assert status.tokenization == Tokenization.FIELD @@ -2851,13 +2858,16 @@ def test_property_reindex_multi_tenant(collection_factory: CollectionFactory) -> collection.with_tenant("tenant1").data.insert_many([{"age": i} for i in range(5)]) status = collection.config.update_property_index( - "age", "rangeFilters", tenants=["tenant1", "tenant2"], wait_for_completion=True + "age", + InvertedIndexType.RANGE_FILTERS, + tenants=["tenant1", "tenant2"], + wait_for_completion=True, ) assert status.type == "rangeFilters" assert status.status == InvertedIndexState.READY status = collection.config.rebuild_property_index( - "age", "rangeFilters", tenants=["tenant1"], wait_for_completion=True + "age", InvertedIndexType.RANGE_FILTERS, tenants=["tenant1"], wait_for_completion=True ) assert status.type == "rangeFilters" assert status.status == InvertedIndexState.READY @@ -2882,13 +2892,16 @@ async def test_property_reindex_async(async_collection_factory: AsyncCollectionF await collection.data.insert_many([{"name": f"object {i}"} for i in range(10)]) status = await collection.config.update_property_index( - "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.WORD, + wait_for_completion=True, ) assert status.type == "searchable" assert status.status == InvertedIndexState.READY task = await collection.config.update_property_index( - "name", "searchable", tokenization=Tokenization.WORD + "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.WORD ) assert task.status == InvertedIndexTaskStatus.NO_OP @@ -2896,9 +2909,9 @@ async def test_property_reindex_async(async_collection_factory: AsyncCollectionF assert indexes.collection == collection.name status = await collection.config.rebuild_property_index( - "name", "searchable", wait_for_completion=True + "name", InvertedIndexType.SEARCHABLE, wait_for_completion=True ) assert status.status == InvertedIndexState.READY - task = await collection.config.cancel_property_index_task("name", "searchable") + 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 index 2a21c8128..c6813ea13 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -421,6 +421,7 @@ def test_update_property_index_enum_and_literal_hit_same_route( 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 @@ -453,6 +454,7 @@ def test_delete_property_index_enum_and_literal_hit_same_route( 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 @@ -478,6 +480,7 @@ def test_rebuild_property_index_enum_and_literal_hit_same_route( 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 @@ -502,6 +505,7 @@ def test_cancel_property_index_task_enum_and_literal_hit_same_route( 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 diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 7041b4e24..035ae95e7 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -101,7 +101,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -112,7 +112,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -123,7 +123,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], @@ -132,12 +132,12 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): async def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> InvertedIndexTask: ... async def cancel_property_index_task( - self, property_name: str, index_name: Union[InvertedIndexType, IndexName] + 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 0b855a22e..7fb371bce 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -680,7 +680,8 @@ 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 or one of + the literals `searchable`, `filterable` or `rangeFilters`. Raises: weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. @@ -753,7 +754,7 @@ async def _execute() -> InvertedIndexStatus: def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -765,7 +766,7 @@ def update_property_index( def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -776,7 +777,7 @@ def update_property_index( def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -798,8 +799,7 @@ def update_property_index( Args: property_name: The property whose index to create or migrate. - index_name: The type of the index, a `InvertedIndexType` value or one of the literals - `searchable`, `filterable` or `rangeFilters`. + 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`. @@ -821,17 +821,19 @@ def update_property_index( weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. """ self.__check_property_reindex_support("Collection config update_property_index") - if isinstance(index_name, InvertedIndexType): - index_name = cast(IndexName, index_name.value) + 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)]) _validate_input( [_ValidateArgument(expected=[str, List[str], None], name="tenants", value=tenants)] ) - path = self.__property_index_path(property_name, index_name) + path = self.__property_index_path(property_name, index) body: Dict[str, Any] = {} if tokenization is not None: body["tokenization"] = ( @@ -867,7 +869,7 @@ async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: task = resp(res) if wait_for_completion: return await executor.aresult( - self.__wait_for_property_index(property_name, index_name) + self.__wait_for_property_index(property_name, index) ) return task @@ -883,14 +885,14 @@ async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: ) task = resp(res) if wait_for_completion: - return executor.result(self.__wait_for_property_index(property_name, index_name)) + return executor.result(self.__wait_for_property_index(property_name, index)) return task @overload def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], @@ -900,7 +902,7 @@ def rebuild_property_index( def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, @@ -909,7 +911,7 @@ def rebuild_property_index( def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, @@ -918,8 +920,7 @@ def rebuild_property_index( Args: property_name: The property whose index to rebuild. - index_name: The type of the index, a `InvertedIndexType` value or one of the literals - `searchable`, `filterable` or `rangeFilters`. + 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 wait until the index reports `ready`. By default False. @@ -936,17 +937,19 @@ def rebuild_property_index( weaviate.exceptions.ReindexCanceledError: If `wait_for_completion=True` and the reindexing task was cancelled. """ self.__check_property_reindex_support("Collection config rebuild_property_index") - if isinstance(index_name, InvertedIndexType): - index_name = cast(IndexName, index_name.value) + 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)]) _validate_input( [_ValidateArgument(expected=[str, List[str], None], name="tenants", value=tenants)] ) - path = self.__property_index_path(property_name, index_name) + "/rebuild" + path = self.__property_index_path(property_name, index) + "/rebuild" if isinstance(tenants, str): tenants = [tenants] params: Optional[Dict[str, Any]] = ( @@ -975,7 +978,7 @@ async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: task = resp(res) if wait_for_completion: return await executor.aresult( - self.__wait_for_property_index(property_name, index_name) + self.__wait_for_property_index(property_name, index) ) return task @@ -991,13 +994,13 @@ async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: ) task = resp(res) if wait_for_completion: - return executor.result(self.__wait_for_property_index(property_name, index_name)) + return executor.result(self.__wait_for_property_index(property_name, index)) return task def cancel_property_index_task( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, ) -> executor.Result[InvertedIndexTask]: """Cancel the live reindexing task of a property index. @@ -1008,8 +1011,7 @@ def cancel_property_index_task( Args: property_name: The property whose reindexing task to cancel. - index_name: The type of the index, a `InvertedIndexType` value or one of the literals - `searchable`, `filterable` or `rangeFilters`. + 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. @@ -1020,14 +1022,16 @@ def cancel_property_index_task( weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. """ self.__check_property_reindex_support("Collection config cancel_property_index_task") - if isinstance(index_name, InvertedIndexType): - index_name = cast(IndexName, index_name.value) + 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 = self.__property_index_path(property_name, index_name) + "/cancel" + path = self.__property_index_path(property_name, index) + "/cancel" def resp(res: Response) -> InvertedIndexTask: response = _decode_json_response_dict(res, "Cancel property index task") diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index 869d51f78..eb2112c9d 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -99,7 +99,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -110,7 +110,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def update_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, algorithm: Optional[Literal["blockmax"]] = None, @@ -121,7 +121,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], @@ -130,12 +130,12 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): def rebuild_property_index( self, property_name: str, - index_name: Union[InvertedIndexType, IndexName], + index_name: InvertedIndexType, *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> InvertedIndexTask: ... def cancel_property_index_task( - self, property_name: str, index_name: Union[InvertedIndexType, IndexName] + self, property_name: str, index_name: InvertedIndexType ) -> InvertedIndexTask: ... def get_property_indexes(self) -> CollectionInvertedIndexes: ... From de8c7befe808ba87cf57e2796ab877aaa10679ba Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:50:08 +0200 Subject: [PATCH 11/14] fix: align CI and task-status handling with final 1.39 semantics (#2098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI pin 1.39.0-rc.0-b41225e was a stale mid-review build of core #12252 that emitted an IN_PROGRESS task status which never shipped; pin 1.39.0-rc.1 instead. Parse the task status tolerantly — known values map to InvertedIndexTaskStatus, unknown values pass through as plain strings, since the spec declares the field an open-vocabulary string — and pin that with a mock test. Upgrade the coupled-tokenization test to the final join contract: an identical re-PUT while the task is in flight returns 202 with the existing taskId and STARTED, verified live against 1.39.0-rc.1 (post-completion re-PUT stays 200 NO_OP). --- .github/workflows/main.yaml | 2 +- integration/test_collection_config.py | 12 +++++++++++- mock_tests/test_property_reindex.py | 19 +++++++++++++++++++ weaviate/collections/classes/config.py | 8 +++++++- .../collections/classes/config_methods.py | 8 +++++++- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d62caf8c2..59733dc27 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -29,7 +29,7 @@ env: WEAVIATE_135: 1.35.18 WEAVIATE_136: 1.36.12 WEAVIATE_137: 1.37.5-e0fe0d5.amd64 - WEAVIATE_139: 1.39.0-rc.0-b41225e.amd64 + WEAVIATE_139: 1.39.0-rc.1 jobs: lint-and-format: diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index d349d1d3b..f4460cd6b 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -2824,6 +2824,16 @@ def test_property_reindex_coupled_tokenization_change( 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") @@ -2837,7 +2847,7 @@ def test_property_reindex_coupled_tokenization_change( # the task already finalized before the first poll assert searchable.tokenization == Tokenization.FIELD - # poll the status endpoint (via the wait path of a NO_OP upsert) until the migration is done + # poll the status endpoint (joining the in-flight task via the wait path) until done status = collection.config.update_property_index( "name", InvertedIndexType.SEARCHABLE, diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index c6813ea13..a6e9fd920 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -134,6 +134,25 @@ def test_update_property_index_wait_for_completion( 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: diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index cfcb3b6d6..6fc39a995 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -2307,8 +2307,14 @@ class InvertedIndexState(str, BaseEnum): @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: InvertedIndexTaskStatus + status: Union[InvertedIndexTaskStatus, str] InvertedIndexTask = _InvertedIndexTask diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index dbb7762d2..e33b80b56 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -568,9 +568,15 @@ def _references_from_config(schema: Dict[str, Any]) -> List[_ReferenceProperty]: 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=InvertedIndexTaskStatus(response["status"]), + status=status, ) From a2acb0003da7f709e90eaa3e586cd1826297b102 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:38:39 +0200 Subject: [PATCH 12/14] fix(config): address review feedback on runtime property reindex (#2098) - wait_for_completion: gate terminal acceptance on the submitted task_id so a stale ready/failed/cancelled entry left by a prior reindex can no longer be mistaken for this task completing (fixes premature return and spurious raise) - extract shared __submit_property_index_task for update/rebuild; point delete_property_index at the __property_index_path helper - deprecate string index_name: emit Dep030 when a raw string is passed to delete_property_index; the reindex methods take InvertedIndexType - InvertedIndexStatus.type is now the InvertedIndexType enum; parse the status field tolerantly so an unknown state cannot crash the poll loop - validate wait_for_completion; normalize an empty tenants list to no param - tests: async mock coverage, a multi-poll transition regression test, a real in-flight CANCELLED e2e, and split the one-change-per-request PUT test --- integration/test_collection_config.py | 50 +++ mock_tests/test_property_reindex.py | 175 +++++++++- weaviate/collections/classes/config.py | 16 +- .../collections/classes/config_methods.py | 15 +- weaviate/collections/config/executor.py | 310 ++++++++++-------- weaviate/outputs/config.py | 10 +- weaviate/warnings.py | 9 + 7 files changed, 441 insertions(+), 144 deletions(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index f4460cd6b..71bd9fddb 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -47,6 +47,7 @@ ) from weaviate.collections.classes.tenants import Tenant from weaviate.exceptions import ( + ReindexCanceledError, UnexpectedStatusCodeError, WeaviateInvalidInputError, WeaviateUnsupportedFeatureError, @@ -2863,6 +2864,55 @@ def test_property_reindex_coupled_tokenization_change( 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.status == 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") diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index a6e9fd920..a667c36ae 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -1,4 +1,5 @@ import json +import warnings from typing import Generator, Union import grpc @@ -51,16 +52,36 @@ def client_139( 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", "algorithm": "blockmax"}, + 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 also a valid single-change PUT body.""" + 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="blockmax", ) assert task.task_id == TASK_ID @@ -112,6 +133,7 @@ def test_update_property_index_wait_for_completion( method="PUT", json={"tokenization": "word"}, ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) + # the ready entry still carries the submitted task id, so the task_id-gated wait accepts it weaviate_139_mock.expect_request(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( { "collection": COLLECTION, @@ -119,7 +141,14 @@ def test_update_property_index_wait_for_completion( { "name": "name", "dataType": "text", - "indexes": [{"type": "searchable", "status": "ready", "tokenization": "word"}], + "indexes": [ + { + "type": "searchable", + "status": "ready", + "taskId": TASK_ID, + "tokenization": "word", + } + ], } ], } @@ -378,6 +407,8 @@ def test_get_property_indexes( 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.status == InvertedIndexState.INDEXING assert searchable.progress == 0.5 @@ -591,6 +622,26 @@ def test_delete_property_index_surfaces_server_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: @@ -606,6 +657,126 @@ def test_property_reindex_invalid_input( weaviate_139_mock.check_assertions() +def _single_index_payload(entry: dict) -> dict: + return { + "collection": COLLECTION, + "properties": [{"name": "name", "dataType": "text", "indexes": [entry]}], + } + + +def test_update_property_index_wait_polls_until_submitted_task_ready( + weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient +) -> None: + """Regression for the task_id gate (fix #1). + + The wait must ignore a stale ``ready`` left by a PRIOR reindex and only accept the ``ready`` + that follows the submitted task's own progress — never returning early. If the task_id gate + were removed, the first (stale) ready would be returned and the tokenization assertion below + would fail. + """ + 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 ready from a prior reindex (different task id) — must NOT be accepted + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/indexes", method="GET" + ).respond_with_json( + _single_index_payload( + { + "type": "searchable", + "status": "ready", + "taskId": "stale-task", + "tokenization": "word", + } + ) + ) + # poll 2: our task mid-finalize (indexing @ 1.0 carrying our task id) + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/indexes", method="GET" + ).respond_with_json( + _single_index_payload( + { + "type": "searchable", + "status": "indexing", + "progress": 1.0, + "taskId": TASK_ID, + "tokenization": "word", + "targetTokenization": "field", + } + ) + ) + # poll 3: flipped to plain ready with the new tokenization + weaviate_139_mock.expect_ordered_request( + f"{SCHEMA_PATH}/indexes", method="GET" + ).respond_with_json( + _single_index_payload({"type": "searchable", "status": "ready", "tokenization": "field"}) + ) + + status = client_139.collections.use(COLLECTION).config.update_property_index( + "name", + InvertedIndexType.SEARCHABLE, + tokenization=Tokenization.FIELD, + wait_for_completion=True, + ) + assert status.status == InvertedIndexState.READY + assert status.tokenization == Tokenization.FIELD + 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 via the task_id gate.""" + 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(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( + _single_index_payload( + {"type": "searchable", "status": "ready", "taskId": TASK_ID, "tokenization": "word"} + ) + ) + + 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.WORD, + wait_for_completion=True, + ) + assert status.status == InvertedIndexState.READY + assert status.tokenization == Tokenization.WORD + 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: diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 6fc39a995..5988547c7 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", @@ -2322,8 +2328,14 @@ class _InvertedIndexTask(_ConfigBase): @dataclass class _InvertedIndexStatus(_ConfigBase): - type: IndexName # noqa: A003 - status: InvertedIndexState + """The status of a single property index as reported by the index status endpoint. + + Known `status` values parse to `InvertedIndexState`; unknown server values pass through + as plain strings, so that polling a status endpoint never crashes on a new state name. + """ + + type: InvertedIndexType # noqa: A003 + status: Union[InvertedIndexState, str] progress: Optional[float] task_id: Optional[str] tokenization: Optional[Tokenization] diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index e33b80b56..7282e7795 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -4,9 +4,9 @@ from weaviate.collections.classes.config import ( DataType, GenerativeSearches, - IndexName, InvertedIndexState, InvertedIndexTaskStatus, + InvertedIndexType, PQEncoderDistribution, PQEncoderType, ReplicationDeletionStrategy, @@ -583,9 +583,18 @@ def _inverted_index_task_from_json(response: Dict[str, Any]) -> _InvertedIndexTa def _inverted_index_status_from_json(index: Dict[str, Any]) -> _InvertedIndexStatus: tokenization = index.get("tokenization") target_tokenization = index.get("targetTokenization") + raw_status = index["status"] + try: + status: Union[InvertedIndexState, str] = InvertedIndexState(raw_status) + except ValueError: + # the spec declares the field open-vocabulary; pass unknown values through so that + # polling a status endpoint never crashes on a newly-added state name + status = raw_status return _InvertedIndexStatus( - type=cast(IndexName, index["type"]), - status=InvertedIndexState(index["status"]), + # `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"]), + status=status, progress=index.get("progress"), task_id=index.get("taskId"), tokenization=Tokenization(tokenization) if tokenization is not None else None, diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 7fb371bce..342b9720e 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -102,23 +102,45 @@ def _find_property_index_status( return None -def _terminal_property_index_status( - entry: Optional[InvertedIndexStatus], property_name: str, index_name: IndexName -) -> Optional[InvertedIndexStatus]: - """Return the entry once it is ready, raise on failure/cancellation, or return None to keep polling.""" +def _property_index_wait_step( + entry: Optional[InvertedIndexStatus], + task_id: str, + task_seen: bool, + property_name: str, + index_name: IndexName, +) -> Tuple[Optional[InvertedIndexStatus], bool]: + """Decide one poll step of a ``task_id``-gated wait. + + Returns ``(terminal_entry_or_None, task_seen)``. A ``None`` terminal means "keep polling". + Terminal acceptance is gated on the submitted ``task_id`` so that we never mistake a stale + entry from a prior reindex for the result of the task we just submitted: + + - ``failed``/``cancelled`` is terminal (raises) ONLY when the entry belongs to ``task_id``; + a stale failed/cancelled entry with a different/absent task id is ignored (keep polling). + - ``ready`` is terminal ONLY after we have observed the entry driven by ``task_id`` on this + or an earlier poll (``task_seen``). During the finalize window the entry shows + ``indexing`` at progress 1.0 WITH the task id before it flips to a plain ``ready`` (which + may carry no task id), so ``task_id`` is observable before ``ready`` — a ``ready`` not yet + associated with ``task_id`` is the stale pre-flip state and must not be accepted. + """ if entry is None: - return None - if entry.status == InvertedIndexState.READY: - return entry - if entry.status == InvertedIndexState.FAILED: + return None, task_seen + belongs = entry.task_id == task_id + if belongs: + task_seen = True + if belongs and entry.status == InvertedIndexState.FAILED: raise ReindexFailedError( - f"Reindexing the '{index_name}' index of property '{property_name}' failed." + f"Reindexing the '{index_name}' index of property '{property_name}' failed " + f"(task '{task_id}'). Inspect GET /v1/tasks for the failure detail." ) - if entry.status == InvertedIndexState.CANCELLED: + if belongs and entry.status == InvertedIndexState.CANCELLED: raise ReindexCanceledError( - f"Reindexing the '{index_name}' index of property '{property_name}' was cancelled." + f"Reindexing the '{index_name}' index of property '{property_name}' was cancelled " + f"(task '{task_id}')." ) - return None + if task_seen and entry.status == InvertedIndexState.READY: + return entry, task_seen + return None, task_seen class _ConfigCollectionExecutor(Generic[ConnectionType]): @@ -680,26 +702,27 @@ 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, an `InvertedIndexType` value or one of - the literals `searchable`, `filterable` or `rangeFilters`. + 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 isinstance(index_name, InvertedIndexType): - index_name = cast(IndexName, index_name.value) + 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 = ( - f"/schema/{_capitalize_first_letter(self._name)}" - + f"/properties/{property_name}" - + f"/index/{index_name}" - ) + path = self.__property_index_path(property_name, index) def resp(res: Response) -> bool: return res.status_code == 200 @@ -728,28 +751,134 @@ def __property_index_path(self, property_name: str, index_name: IndexName) -> st ) def __wait_for_property_index( - self, property_name: str, index_name: IndexName + self, property_name: str, index_name: IndexName, task: InvertedIndexTask ) -> executor.Result[InvertedIndexStatus]: + """Poll the index status endpoint until the submitted ``task`` reaches a terminal state. + + A ``NO_OP`` submission (no task id) means the configuration already matched, so the + current status is fetched once and returned without polling. Otherwise the poll is gated + on ``task.task_id`` (see ``_property_index_wait_step``). There is deliberately no timeout, + matching the export/backup wait precedent; if the targeted entry never appears (e.g. the + index is deleted out from under the wait) the loop spins indefinitely. + """ + task_id = task.task_id 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) + assert entry is not None + return entry + task_seen = False while True: indexes = await executor.aresult(self.get_property_indexes()) entry = _find_property_index_status(indexes, property_name, index_name) - done = _terminal_property_index_status(entry, property_name, index_name) + done, task_seen = _property_index_wait_step( + entry, task_id, task_seen, property_name, index_name + ) if done is not None: return done await asyncio.sleep(1) return _execute() + if task_id is None: + indexes = executor.result(self.get_property_indexes()) + entry = _find_property_index_status(indexes, property_name, index_name) + assert entry is not None + return entry + task_seen = False while True: indexes = executor.result(self.get_property_indexes()) entry = _find_property_index_status(indexes, property_name, index_name) - done = _terminal_property_index_status(entry, property_name, index_name) + done, task_seen = _property_index_wait_step( + entry, task_id, task_seen, property_name, index_name + ) if done is not None: return done time.sleep(1) + 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, + 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 ``task_id``-gated 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 + ) + ] + ) + + 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 + + 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) + ) + 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)) + return task + @overload def update_property_index( self, @@ -825,15 +954,6 @@ def update_property_index( 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)]) - _validate_input( - [_ValidateArgument(expected=[str, List[str], None], name="tenants", value=tenants)] - ) - - path = self.__property_index_path(property_name, index) body: Dict[str, Any] = {} if tokenization is not None: body["tokenization"] = ( @@ -841,52 +961,18 @@ def update_property_index( ) if algorithm is not None: body["algorithm"] = algorithm - if isinstance(tenants, str): - tenants = [tenants] - params: Optional[Dict[str, Any]] = ( - {"tenants": ",".join(tenants)} if tenants is not None else None - ) - - def resp(res: Response) -> InvertedIndexTask: - response = _decode_json_response_dict(res, "Update property index") - 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( - self._connection.put( - path=path, - weaviate_object=body, - params=params, - error_msg="Property index may not have been updated.", - status_codes=_ExpectedStatusCodes( - ok_in=[200, 202], error="Update property index" - ), - ) - ) - task = resp(res) - if wait_for_completion: - return await executor.aresult( - self.__wait_for_property_index(property_name, index) - ) - return task - - return _execute() - res = executor.result( - self._connection.put( - path=path, - weaviate_object=body, - params=params, - error_msg="Property index may not have been updated.", - status_codes=_ExpectedStatusCodes(ok_in=[200, 202], error="Update property index"), - ) + 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, + error_verb="updated", + error_label="Update property index", + ok_in=[200, 202], ) - task = resp(res) - if wait_for_completion: - return executor.result(self.__wait_for_property_index(property_name, index)) - return task @overload def rebuild_property_index( @@ -941,61 +1027,18 @@ def rebuild_property_index( 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)]) - _validate_input( - [_ValidateArgument(expected=[str, List[str], None], name="tenants", value=tenants)] - ) - - path = self.__property_index_path(property_name, index) + "/rebuild" - if isinstance(tenants, str): - tenants = [tenants] - params: Optional[Dict[str, Any]] = ( - {"tenants": ",".join(tenants)} if tenants is not None else None - ) - - def resp(res: Response) -> InvertedIndexTask: - response = _decode_json_response_dict(res, "Rebuild property index") - 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( - self._connection.post( - path=path, - weaviate_object={}, - params=params, - error_msg="Property index may not have been rebuilt.", - status_codes=_ExpectedStatusCodes( - ok_in=[202], error="Rebuild property index" - ), - ) - ) - task = resp(res) - if wait_for_completion: - return await executor.aresult( - self.__wait_for_property_index(property_name, index) - ) - return task - - return _execute() - res = executor.result( - self._connection.post( - path=path, - weaviate_object={}, - params=params, - error_msg="Property index may not have been rebuilt.", - status_codes=_ExpectedStatusCodes(ok_in=[202], error="Rebuild property index"), - ) + 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, + error_verb="rebuilt", + error_label="Rebuild property index", + ok_in=[202], ) - task = resp(res) - if wait_for_completion: - return executor.result(self.__wait_for_property_index(property_name, index)) - return task def cancel_property_index_task( self, @@ -1038,6 +1081,7 @@ def resp(res: Response) -> InvertedIndexTask: 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.post, diff --git a/weaviate/outputs/config.py b/weaviate/outputs/config.py index 7f156ce50..73172d85d 100644 --- a/weaviate/outputs/config.py +++ b/weaviate/outputs/config.py @@ -11,6 +11,7 @@ InvertedIndexStatus, InvertedIndexTask, InvertedIndexTaskStatus, + InvertedIndexType, MultiTenancyConfig, PQConfig, PQEncoderConfig, @@ -45,6 +46,11 @@ "GenerativeConfig", "GenerativeSearches", "InvertedIndexConfig", + "InvertedIndexState", + "InvertedIndexStatus", + "InvertedIndexTask", + "InvertedIndexTaskStatus", + "InvertedIndexType", "MultiTenancyConfig", "ReplicationDeletionStrategy", "PQConfig", @@ -53,10 +59,6 @@ "PQEncoderType", "PropertyConfig", "PropertyInvertedIndexes", - "InvertedIndexState", - "InvertedIndexStatus", - "InvertedIndexTask", - "InvertedIndexTaskStatus", "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( From 2c7d2fed4f2277473ee7880dfcc7bd94f8b27d71 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:07:16 +0200 Subject: [PATCH 13/14] refactor(config): reindex surface polish: .state field + BM25Algorithm enum (#2098) - rename the poll-entry lifecycle field InvertedIndexStatus.status to .state so the field name matches its InvertedIndexState enum and no longer collides with InvertedIndexTask.status (review feedback) - add BM25Algorithm(str, BaseEnum) {wand, blockmax}; update_property_index takes algorithm: Optional[BM25Algorithm] (serialized to the wire value), and the status entry's algorithm/target_algorithm parse tolerantly into it --- integration/test_collection_config.py | 27 +++++---- mock_tests/test_property_reindex.py | 56 ++++++++++++++++--- weaviate/classes/config.py | 2 + weaviate/collections/classes/config.py | 28 +++++++--- .../collections/classes/config_methods.py | 26 ++++++--- weaviate/collections/config/async_.pyi | 5 +- weaviate/collections/config/executor.py | 23 +++++--- weaviate/collections/config/sync.pyi | 5 +- weaviate/outputs/config.py | 2 + 9 files changed, 127 insertions(+), 47 deletions(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index 71bd9fddb..b6591f786 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -41,6 +41,7 @@ _NamedVectorConfigCreate, _VectorizerConfigCreate, IndexName, + BM25Algorithm, InvertedIndexState, InvertedIndexTaskStatus, InvertedIndexType, @@ -2727,7 +2728,7 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac wait_for_completion=True, ) assert status.type == "searchable" - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY assert status.tokenization == Tokenization.WORD # re-putting the matching configuration is a no-op @@ -2747,14 +2748,16 @@ def test_property_reindex_searchable_lifecycle(collection_factory: CollectionFac for index in prop.indexes if index.type == "searchable" ) - assert entry.status == InvertedIndexState.READY + 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.status == InvertedIndexState.READY + 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) @@ -2786,7 +2789,7 @@ def test_property_reindex_range_filters(collection_factory: CollectionFactory) - "age", InvertedIndexType.RANGE_FILTERS, wait_for_completion=True ) assert status.type == "rangeFilters" - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY entry = next( index @@ -2795,7 +2798,7 @@ def test_property_reindex_range_filters(collection_factory: CollectionFactory) - for index in prop.indexes if index.type == "rangeFilters" ) - assert entry.status == InvertedIndexState.READY + assert entry.state == InvertedIndexState.READY def test_property_reindex_coupled_tokenization_change( @@ -2855,12 +2858,12 @@ def test_property_reindex_coupled_tokenization_change( tokenization=Tokenization.FIELD, wait_for_completion=True, ) - assert status.status == InvertedIndexState.READY + 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.status == InvertedIndexState.READY + assert filterable.state == InvertedIndexState.READY assert filterable.tokenization == Tokenization.FIELD @@ -2908,7 +2911,7 @@ def test_property_reindex_cancel_in_flight(collection_factory: CollectionFactory wait_for_completion=True, ) # a resubmit may relaunch and complete instead of surfacing the cancelled entry - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY except ReindexCanceledError: pass @@ -2940,13 +2943,13 @@ def test_property_reindex_multi_tenant(collection_factory: CollectionFactory) -> wait_for_completion=True, ) assert status.type == "rangeFilters" - assert status.status == InvertedIndexState.READY + 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.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY @pytest.mark.asyncio @@ -2974,7 +2977,7 @@ async def test_property_reindex_async(async_collection_factory: AsyncCollectionF wait_for_completion=True, ) assert status.type == "searchable" - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY task = await collection.config.update_property_index( "name", InvertedIndexType.SEARCHABLE, tokenization=Tokenization.WORD @@ -2987,7 +2990,7 @@ async def test_property_reindex_async(async_collection_factory: AsyncCollectionF status = await collection.config.rebuild_property_index( "name", InvertedIndexType.SEARCHABLE, wait_for_completion=True ) - assert status.status == InvertedIndexState.READY + 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 index a667c36ae..bb81a1e62 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -10,6 +10,7 @@ import weaviate from mock_tests.conftest import MOCK_IP, MOCK_PORT, MOCK_PORT_GRPC from weaviate.collections.classes.config import ( + BM25Algorithm, DataType, InvertedIndexState, InvertedIndexTaskStatus, @@ -72,7 +73,7 @@ def test_update_property_index_started( def test_update_property_index_algorithm_only( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: - """An algorithm-only change is also a valid single-change PUT body.""" + """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", @@ -82,7 +83,7 @@ def test_update_property_index_algorithm_only( task = client_139.collections.use(COLLECTION).config.update_property_index( "name", "searchable", - algorithm="blockmax", + algorithm=BM25Algorithm.BLOCKMAX, ) assert task.task_id == TASK_ID assert task.status == InvertedIndexTaskStatus.STARTED @@ -158,7 +159,7 @@ def test_update_property_index_wait_for_completion( "name", "searchable", tokenization=Tokenization.WORD, wait_for_completion=True ) assert status.type == "searchable" - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY assert status.tokenization == Tokenization.WORD weaviate_139_mock.check_assertions() @@ -410,12 +411,15 @@ def test_get_property_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.status == InvertedIndexState.INDEXING + 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 @@ -429,7 +433,7 @@ def test_get_property_indexes( assert age.description is None assert len(age.indexes) == 1 assert age.indexes[0].type == "rangeFilters" - assert age.indexes[0].status == InvertedIndexState.READY + 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 @@ -440,7 +444,7 @@ def test_get_property_indexes( 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]["status"] == "ready" + assert out["properties"][1]["indexes"][0]["state"] == "ready" weaviate_139_mock.check_assertions() @@ -599,6 +603,42 @@ def test_get_property_indexes_reference_property( 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: @@ -720,7 +760,7 @@ def test_update_property_index_wait_polls_until_submitted_task_ready( tokenization=Tokenization.FIELD, wait_for_completion=True, ) - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY assert status.tokenization == Tokenization.FIELD weaviate_139_mock.check_assertions() @@ -750,7 +790,7 @@ async def test_update_property_index_async( tokenization=Tokenization.WORD, wait_for_completion=True, ) - assert status.status == InvertedIndexState.READY + assert status.state == InvertedIndexState.READY assert status.tokenization == Tokenization.WORD weaviate_139_mock.check_assertions() diff --git a/weaviate/classes/config.py b/weaviate/classes/config.py index a806aa04a..5a4e591b7 100644 --- a/weaviate/classes/config.py +++ b/weaviate/classes/config.py @@ -1,4 +1,5 @@ from weaviate.collections.classes.config import ( + BM25Algorithm, Configure, ConsistencyLevel, DataType, @@ -27,6 +28,7 @@ from weaviate.connect.integrations import Integrations __all__ = [ + "BM25Algorithm", "Configure", "ConsistencyLevel", "Reconfigure", diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 5988547c7..e7efc7168 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -114,7 +114,7 @@ # 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 +# 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", @@ -221,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. @@ -2328,20 +2340,22 @@ class _InvertedIndexTask(_ConfigBase): @dataclass class _InvertedIndexStatus(_ConfigBase): - """The status of a single property index as reported by the index status endpoint. + """A snapshot of a single property index as reported by the index status endpoint. - Known `status` values parse to `InvertedIndexState`; unknown server values pass through - as plain strings, so that polling a status endpoint never crashes on a new state name. + 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 - status: Union[InvertedIndexState, str] + state: Union[InvertedIndexState, str] progress: Optional[float] task_id: Optional[str] tokenization: Optional[Tokenization] target_tokenization: Optional[Tokenization] - algorithm: Optional[str] - target_algorithm: Optional[str] + # searchable only: the current and in-flight BM25 scoring algorithm + algorithm: Optional[Union[BM25Algorithm, str]] + target_algorithm: Optional[Union[BM25Algorithm, str]] InvertedIndexStatus = _InvertedIndexStatus diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index 7282e7795..0710bcb35 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union, cast from weaviate.collections.classes.config import ( + BM25Algorithm, DataType, GenerativeSearches, InvertedIndexState, @@ -580,29 +581,40 @@ def _inverted_index_task_from_json(response: Dict[str, Any]) -> _InvertedIndexTa ) +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_status = index["status"] + raw_state = index["status"] try: - status: Union[InvertedIndexState, str] = InvertedIndexState(raw_status) + state: Union[InvertedIndexState, str] = InvertedIndexState(raw_state) except ValueError: # the spec declares the field open-vocabulary; pass unknown values through so that - # polling a status endpoint never crashes on a newly-added state name - status = raw_status + # 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"]), - status=status, + 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=index.get("algorithm"), - target_algorithm=index.get("targetAlgorithm"), + algorithm=_bm25_algorithm_or_raw(index.get("algorithm")), + target_algorithm=_bm25_algorithm_or_raw(index.get("targetAlgorithm")), ) diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 035ae95e7..ccaf43c4a 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -3,6 +3,7 @@ from typing import Dict, List, Literal, Optional, Union, overload from typing_extensions import deprecated from weaviate.collections.classes.config import ( + BM25Algorithm, CollectionConfig, CollectionConfigSimple, CollectionInvertedIndexes, @@ -104,7 +105,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> InvertedIndexStatus: ... @@ -115,7 +116,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> InvertedIndexTask: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 342b9720e..c5405f9ee 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -19,6 +19,7 @@ from typing_extensions import deprecated from weaviate.collections.classes.config import ( + BM25Algorithm, CollectionConfig, CollectionConfigSimple, CollectionInvertedIndexes, @@ -120,7 +121,7 @@ def _property_index_wait_step( - ``ready`` is terminal ONLY after we have observed the entry driven by ``task_id`` on this or an earlier poll (``task_seen``). During the finalize window the entry shows ``indexing`` at progress 1.0 WITH the task id before it flips to a plain ``ready`` (which - may carry no task id), so ``task_id`` is observable before ``ready`` — a ``ready`` not yet + may carry no task id), so ``task_id`` is observable before ``ready``; a ``ready`` not yet associated with ``task_id`` is the stale pre-flip state and must not be accepted. """ if entry is None: @@ -128,17 +129,17 @@ def _property_index_wait_step( belongs = entry.task_id == task_id if belongs: task_seen = True - if belongs and entry.status == InvertedIndexState.FAILED: + if belongs and entry.state == InvertedIndexState.FAILED: raise ReindexFailedError( f"Reindexing the '{index_name}' index of property '{property_name}' failed " f"(task '{task_id}'). Inspect GET /v1/tasks for the failure detail." ) - if belongs and entry.status == InvertedIndexState.CANCELLED: + if belongs and entry.state == InvertedIndexState.CANCELLED: raise ReindexCanceledError( f"Reindexing the '{index_name}' index of property '{property_name}' was cancelled " f"(task '{task_id}')." ) - if task_seen and entry.status == InvertedIndexState.READY: + if task_seen and entry.state == InvertedIndexState.READY: return entry, task_seen return None, task_seen @@ -886,7 +887,7 @@ def update_property_index( index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> executor.Result[InvertedIndexStatus]: ... @@ -898,7 +899,7 @@ def update_property_index( index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> executor.Result[InvertedIndexTask]: ... @@ -909,7 +910,7 @@ def update_property_index( index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: bool = False, ) -> executor.Result[Union[InvertedIndexTask, InvertedIndexStatus]]: @@ -932,7 +933,9 @@ def update_property_index( 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 search algorithm of a `searchable` index. Only `blockmax` may be requested. + 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. @@ -960,7 +963,9 @@ def update_property_index( tokenization.value if isinstance(tokenization, Tokenization) else tokenization ) if algorithm is not None: - body["algorithm"] = algorithm + body["algorithm"] = ( + algorithm.value if isinstance(algorithm, BM25Algorithm) else algorithm + ) return self.__submit_property_index_task( property_name=property_name, index_name=index, diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index eb2112c9d..a25f269ff 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -3,6 +3,7 @@ from typing import Dict, List, Literal, Optional, Union, overload from typing_extensions import deprecated from weaviate.collections.classes.config import ( + BM25Algorithm, CollectionConfig, CollectionConfigSimple, CollectionInvertedIndexes, @@ -102,7 +103,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], ) -> InvertedIndexStatus: ... @@ -113,7 +114,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): index_name: InvertedIndexType, *, tokenization: Optional[Tokenization] = None, - algorithm: Optional[Literal["blockmax"]] = None, + algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, ) -> InvertedIndexTask: ... diff --git a/weaviate/outputs/config.py b/weaviate/outputs/config.py index 73172d85d..d6157edc3 100644 --- a/weaviate/outputs/config.py +++ b/weaviate/outputs/config.py @@ -1,5 +1,6 @@ from weaviate.collections.classes.config import ( AsyncReplicationConfig, + BM25Algorithm, BM25Config, CollectionConfig, CollectionConfigSimple, @@ -39,6 +40,7 @@ __all__ = [ "AsyncReplicationConfig", + "BM25Algorithm", "BM25Config", "CollectionConfig", "CollectionConfigSimple", From 8ef84b89c13810cd175d004414a9356b8b820b61 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:46:31 +0200 Subject: [PATCH 14/14] fix(config): make runtime reindex wait_for_completion correct and bounded (#2098) Reimplement wait_for_completion to poll GET /v1/schema/{class}/indexes (the collection-scoped, namespace-transparent status endpoint) instead of a signal that could hang forever: - completion is the targeted index reaching `ready` with the requested config; config-match cleanly separates a real completion from a stale pre-flip `ready`, and `failed`/`cancelled` are matched by the namespace-clean taskId and raise - add a `timeout` knob (ReindexTimeoutError) plus a no-progress guard, so a server-side fault (a vanished or stalled index entry) is bounded rather than hanging, while a legitimately long reindex is never cut off - reject algorithm=WAND client-side (never a valid target), validate the tokenization/algorithm inputs, and raise a clear error instead of a bare assert when a NO_OP response has no matching index entry --- mock_tests/test_property_reindex.py | 650 ++++++++++++++++++------ weaviate/collections/config/async_.pyi | 4 + weaviate/collections/config/executor.py | 331 +++++++++--- weaviate/collections/config/sync.pyi | 4 + weaviate/exceptions.py | 4 + 5 files changed, 779 insertions(+), 214 deletions(-) diff --git a/mock_tests/test_property_reindex.py b/mock_tests/test_property_reindex.py index bb81a1e62..cf653f9a6 100644 --- a/mock_tests/test_property_reindex.py +++ b/mock_tests/test_property_reindex.py @@ -9,6 +9,7 @@ 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, @@ -20,12 +21,38 @@ from weaviate.exceptions import ( ReindexCanceledError, ReindexFailedError, + ReindexTimeoutError, WeaviateUnsupportedFeatureError, ) COLLECTION = "TestCollection" SCHEMA_PATH = f"/v1/schema/{COLLECTION}" -TASK_ID = "00000000-0000-0000-0000-000000000001" +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") @@ -126,44 +153,362 @@ def test_update_property_index_range_filters_with_tenants( weaviate_139_mock.check_assertions() -def test_update_property_index_wait_for_completion( +def test_update_property_index_wait_tokenization_change( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: - weaviate_139_mock.expect_request( + """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": "word"}, + json={"tokenization": "field"}, ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) - # the ready entry still carries the submitted task id, so the task_id-gated wait accepts it - weaviate_139_mock.expect_request(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( + # 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": "name", - "dataType": "text", - "indexes": [ - { - "type": "searchable", - "status": "ready", - "taskId": TASK_ID, - "tokenization": "word", - } - ], + "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.type == "searchable" 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: @@ -201,89 +546,119 @@ def test_update_property_index_bare_str_tenant( weaviate_139_mock.check_assertions() -@pytest.mark.parametrize( - "index_status,exception", - [("failed", ReindexFailedError), ("cancelled", ReindexCanceledError)], -) -def test_update_property_index_wait_raises( - weaviate_139_mock: HTTPServer, - client_139: weaviate.WeaviateClient, - index_status: str, - exception: type, +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(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( - { - "collection": COLLECTION, - "properties": [ - { - "name": "name", - "dataType": "text", - "indexes": [ - { - "type": "searchable", - "status": index_status, - "progress": 0.42, - "taskId": TASK_ID, - "tokenization": "word", - } - ], - } - ], - } + weaviate_139_mock.expect_request(INDEXES_PATH, method="GET").respond_with_json( + _indexes(status="failed", taskId=TASK_ID, tokenization="word") ) - with pytest.raises(exception): + 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() -@pytest.mark.parametrize( - "index_status,exception", - [("failed", ReindexFailedError), ("cancelled", ReindexCanceledError)], -) -def test_rebuild_property_index_wait_raises( - weaviate_139_mock: HTTPServer, - client_139: weaviate.WeaviateClient, - index_status: str, - exception: type, +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) - 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": index_status, - "progress": 0.42, - "taskId": TASK_ID, - "tokenization": "word", - } - ], - } - ], - } + # 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") ) - with pytest.raises(exception): - client_139.collections.use(COLLECTION).config.rebuild_property_index( - "name", "searchable", wait_for_completion=True - ) + 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() @@ -697,101 +1072,88 @@ def test_property_reindex_invalid_input( weaviate_139_mock.check_assertions() -def _single_index_payload(entry: dict) -> dict: - return { - "collection": COLLECTION, - "properties": [{"name": "name", "dataType": "text", "indexes": [entry]}], - } +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_wait_polls_until_submitted_task_ready( +def test_update_property_index_rejects_garbage_config_types( weaviate_139_mock: HTTPServer, client_139: weaviate.WeaviateClient ) -> None: - """Regression for the task_id gate (fix #1). + """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() + - The wait must ignore a stale ``ready`` left by a PRIOR reindex and only accept the ``ready`` - that follows the submitted task's own progress — never returning early. If the task_id gate - were removed, the first (stale) ready would be returned and the tokenization assertion below - would fail. +@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_ordered_request( + 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) - # poll 1: a stale ready from a prior reindex (different task id) — must NOT be accepted - weaviate_139_mock.expect_ordered_request( - f"{SCHEMA_PATH}/indexes", method="GET" - ).respond_with_json( - _single_index_payload( - { - "type": "searchable", - "status": "ready", - "taskId": "stale-task", - "tokenization": "word", - } - ) - ) - # poll 2: our task mid-finalize (indexing @ 1.0 carrying our task id) - weaviate_139_mock.expect_ordered_request( - f"{SCHEMA_PATH}/indexes", method="GET" - ).respond_with_json( - _single_index_payload( - { - "type": "searchable", - "status": "indexing", - "progress": 1.0, - "taskId": TASK_ID, - "tokenization": "word", - "targetTokenization": "field", - } - ) - ) - # poll 3: flipped to plain ready with the new tokenization - weaviate_139_mock.expect_ordered_request( - f"{SCHEMA_PATH}/indexes", method="GET" - ).respond_with_json( - _single_index_payload({"type": "searchable", "status": "ready", "tokenization": "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", - InvertedIndexType.SEARCHABLE, - tokenization=Tokenization.FIELD, - wait_for_completion=True, - ) + 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( +async def test_update_property_index_async_timeout( weaviate_139_mock: HTTPServer, start_grpc_server: grpc.Server ) -> None: - """The async fork of update_property_index submits and waits via the task_id gate.""" + """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": "word"}, + json={"tokenization": "field"}, ).respond_with_json({"taskId": TASK_ID, "status": "STARTED"}, status=202) - weaviate_139_mock.expect_request(f"{SCHEMA_PATH}/indexes", method="GET").respond_with_json( - _single_index_payload( - {"type": "searchable", "status": "ready", "taskId": TASK_ID, "tokenization": "word"} - ) + 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: - status = await client.collections.use(COLLECTION).config.update_property_index( - "name", - InvertedIndexType.SEARCHABLE, - tokenization=Tokenization.WORD, - wait_for_completion=True, - ) - assert status.state == InvertedIndexState.READY - assert status.tokenization == Tokenization.WORD + 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() diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index ccaf43c4a..d21230d1f 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -108,6 +108,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): 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( @@ -119,6 +120,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): 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( @@ -128,6 +130,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], + timeout: Optional[float] = None, ) -> InvertedIndexStatus: ... @overload async def rebuild_property_index( @@ -137,6 +140,7 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): *, 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 diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index c5405f9ee..c0f231406 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -1,5 +1,6 @@ import asyncio import time +from enum import Enum from typing import ( Any, Dict, @@ -65,6 +66,7 @@ from weaviate.exceptions import ( ReindexCanceledError, ReindexFailedError, + ReindexTimeoutError, WeaviateInvalidInputError, WeaviateUnsupportedFeatureError, ) @@ -103,45 +105,54 @@ def _find_property_index_status( return None -def _property_index_wait_step( - entry: Optional[InvertedIndexStatus], - task_id: str, - task_seen: bool, - property_name: str, - index_name: IndexName, -) -> Tuple[Optional[InvertedIndexStatus], bool]: - """Decide one poll step of a ``task_id``-gated wait. - - Returns ``(terminal_entry_or_None, task_seen)``. A ``None`` terminal means "keep polling". - Terminal acceptance is gated on the submitted ``task_id`` so that we never mistake a stale - entry from a prior reindex for the result of the task we just submitted: - - - ``failed``/``cancelled`` is terminal (raises) ONLY when the entry belongs to ``task_id``; - a stale failed/cancelled entry with a different/absent task id is ignored (keep polling). - - ``ready`` is terminal ONLY after we have observed the entry driven by ``task_id`` on this - or an earlier poll (``task_seen``). During the finalize window the entry shows - ``indexing`` at progress 1.0 WITH the task id before it flips to a plain ``ready`` (which - may carry no task id), so ``task_id`` is observable before ``ready``; a ``ready`` not yet - associated with ``task_id`` is the stale pre-flip state and must not be accepted. +# 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. """ - if entry is None: - return None, task_seen - belongs = entry.task_id == task_id - if belongs: - task_seen = True - if belongs and entry.state == InvertedIndexState.FAILED: - raise ReindexFailedError( - f"Reindexing the '{index_name}' index of property '{property_name}' failed " - f"(task '{task_id}'). Inspect GET /v1/tasks for the failure detail." - ) - if belongs and entry.state == InvertedIndexState.CANCELLED: - raise ReindexCanceledError( - f"Reindexing the '{index_name}' index of property '{property_name}' was cancelled " - f"(task '{task_id}')." - ) - if task_seen and entry.state == InvertedIndexState.READY: - return entry, task_seen - return None, task_seen + # 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]): @@ -752,52 +763,166 @@ def __property_index_path(self, property_name: str, index_name: IndexName) -> st ) def __wait_for_property_index( - self, property_name: str, index_name: IndexName, task: InvertedIndexTask + 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 the index status endpoint until the submitted ``task`` reaches a terminal state. - - A ``NO_OP`` submission (no task id) means the configuration already matched, so the - current status is fetched once and returned without polling. Otherwise the poll is gated - on ``task.task_id`` (see ``_property_index_wait_step``). There is deliberately no timeout, - matching the export/backup wait precedent; if the targeted entry never appears (e.g. the - index is deleted out from under the wait) the loop spins indefinitely. + """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) - assert entry is not None + if entry is None: + raise no_op_missing_error return entry - task_seen = False + 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) - done, task_seen = _property_index_wait_step( - entry, task_id, task_seen, property_name, index_name - ) - if done is not None: - return done - await asyncio.sleep(1) + 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) - assert entry is not None + if entry is None: + raise no_op_missing_error return entry - task_seen = False + 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) - done, task_seen = _property_index_wait_step( - entry, task_id, task_seen, property_name, index_name - ) - if done is not None: - return done - time.sleep(1) + 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, @@ -809,6 +934,7 @@ def __submit_property_index_task( 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], @@ -816,7 +942,7 @@ def __submit_property_index_task( """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 ``task_id``-gated wait all live here. + 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)] @@ -832,6 +958,9 @@ def __submit_property_index_task( ) ] ) + _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): @@ -840,6 +969,10 @@ def __submit_property_index_task( 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) @@ -861,7 +994,15 @@ async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: task = resp(res) if wait_for_completion: return await executor.aresult( - self.__wait_for_property_index(property_name, index_name, task) + self.__wait_for_property_index( + property_name, + index_name, + task, + timeout, + expected_tokenization, + expected_algorithm, + is_rebuild, + ) ) return task @@ -877,7 +1018,17 @@ async def _execute() -> Union[InvertedIndexTask, InvertedIndexStatus]: ) task = resp(res) if wait_for_completion: - return executor.result(self.__wait_for_property_index(property_name, index_name, task)) + return executor.result( + self.__wait_for_property_index( + property_name, + index_name, + task, + timeout, + expected_tokenization, + expected_algorithm, + is_rebuild, + ) + ) return task @overload @@ -890,6 +1041,7 @@ def update_property_index( algorithm: Optional[BM25Algorithm] = None, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], + timeout: Optional[float] = None, ) -> executor.Result[InvertedIndexStatus]: ... @overload @@ -902,6 +1054,7 @@ def update_property_index( 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( @@ -913,6 +1066,7 @@ def update_property_index( 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. @@ -939,7 +1093,11 @@ def update_property_index( 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 wait until the index reports `ready`. By default False. + 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` @@ -951,8 +1109,24 @@ def update_property_index( 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, @@ -963,9 +1137,14 @@ def update_property_index( tokenization.value if isinstance(tokenization, Tokenization) else tokenization ) if algorithm is not None: - body["algorithm"] = ( - algorithm.value if isinstance(algorithm, BM25Algorithm) else algorithm - ) + 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, @@ -974,6 +1153,7 @@ def update_property_index( body=body, tenants=tenants, wait_for_completion=wait_for_completion, + timeout=timeout, error_verb="updated", error_label="Update property index", ok_in=[200, 202], @@ -987,6 +1167,7 @@ def rebuild_property_index( *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], + timeout: Optional[float] = None, ) -> executor.Result[InvertedIndexStatus]: ... @overload @@ -997,6 +1178,7 @@ def rebuild_property_index( *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[False] = False, + timeout: Optional[float] = None, ) -> executor.Result[InvertedIndexTask]: ... def rebuild_property_index( @@ -1006,6 +1188,7 @@ def rebuild_property_index( *, 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. @@ -1014,7 +1197,13 @@ def rebuild_property_index( 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 wait until the index reports `ready`. By default False. + 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` @@ -1026,6 +1215,7 @@ def rebuild_property_index( 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( @@ -1040,6 +1230,7 @@ def rebuild_property_index( body={}, tenants=tenants, wait_for_completion=wait_for_completion, + timeout=timeout, error_verb="rebuilt", error_label="Rebuild property index", ok_in=[202], diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index a25f269ff..ccfa861e5 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -106,6 +106,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): 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( @@ -117,6 +118,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): 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( @@ -126,6 +128,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): *, tenants: Union[List[str], str, None] = None, wait_for_completion: Literal[True], + timeout: Optional[float] = None, ) -> InvertedIndexStatus: ... @overload def rebuild_property_index( @@ -135,6 +138,7 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): *, 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 diff --git a/weaviate/exceptions.py b/weaviate/exceptions.py index a1bcaf6a9..5eca4b7e4 100644 --- a/weaviate/exceptions.py +++ b/weaviate/exceptions.py @@ -157,6 +157,10 @@ 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."""