diff --git a/qdrant_client/conversions/conversion.py b/qdrant_client/conversions/conversion.py index 2c64cb2c7..897672ea3 100644 --- a/qdrant_client/conversions/conversion.py +++ b/qdrant_client/conversions/conversion.py @@ -239,7 +239,7 @@ def convert_collection_info(cls, model: grpc.CollectionInfo) -> rest.CollectionI payload_schema=cls.convert_payload_schema(model.payload_schema), segments_count=model.segments_count, status=cls.convert_collection_status(model.status), - points_count=model.points_count, + points_count=model.points_count if model.HasField("points_count") else None, indexed_vectors_count=( model.indexed_vectors_count if model.HasField("indexed_vectors_count") else None ), @@ -498,7 +498,7 @@ def convert_collection_status(cls, model: grpc.CollectionStatus) -> rest.Collect @classmethod def convert_update_result(cls, model: grpc.UpdateResult) -> rest.UpdateResult: return rest.UpdateResult( - operation_id=model.operation_id, + operation_id=model.operation_id if model.HasField("operation_id") else None, status=cls.convert_update_status(model.status), ) diff --git a/tests/conversions/fixtures.py b/tests/conversions/fixtures.py index 72a7648e1..f99a82453 100644 --- a/tests/conversions/fixtures.py +++ b/tests/conversions/fixtures.py @@ -945,6 +945,15 @@ update_queue=grpc.UpdateQueueInfo(length=42), ) +# `points_count` is `optional` in the proto: the server omits it when the count is +# not available, which must not be reported as an empty collection. +collection_info_no_points_count = grpc.CollectionInfo( + status=collection_status, + optimizer_status=optimizer_status_error, + segments_count=6, + config=collection_config, +) + collection_info_red = grpc.CollectionInfo( status=collection_status_error, optimizer_status=optimizer_status_error, @@ -1088,6 +1097,9 @@ update_result_completed = grpc.UpdateResult(operation_id=201, status=update_status_completed) update_result_wait_timeout = grpc.UpdateResult(operation_id=201, status=update_status_wait_timeout) +# `operation_id` is `optional` in the proto: the server omits it, e.g. when a +# delete-by-filter matches nothing or when an update is clock-rejected. +update_result_no_operation_id = grpc.UpdateResult(status=update_status_completed) delete_alias = grpc.DeleteAlias(alias_name="col3") @@ -1882,6 +1894,7 @@ "Filter": [filter_nested, filter_], "CollectionInfo": [ collection_info, + collection_info_no_points_count, collection_info_ok, collection_info_red, collection_info_grey, @@ -2116,7 +2129,12 @@ "SearchMatrixOffsets": [search_matrix_offsets], "StrictModeConfig": [strict_mode_config, strict_mode_config_empty], "UpdateQueueInfo": [update_queue_info, update_queue_info_deferred], - "UpdateResult": [update_result, update_result_completed, update_result_wait_timeout], + "UpdateResult": [ + update_result, + update_result_completed, + update_result_wait_timeout, + update_result_no_operation_id, + ], "UpdateMode": [update_mode_upsert, update_mode_insert_only, update_mode_update_only], "ReplicaState": [ replica_state_active, diff --git a/tests/conversions/test_validate_conversions.py b/tests/conversions/test_validate_conversions.py index 0ee37d16a..d7c791ab6 100644 --- a/tests/conversions/test_validate_conversions.py +++ b/tests/conversions/test_validate_conversions.py @@ -670,3 +670,58 @@ def round_trip(prefix): grpc_params, recovered = round_trip(False) assert not grpc_params.HasField("prefix") assert recovered.prefix is None + + +def test_convert_update_result_operation_id_presence(): + from qdrant_client import grpc + from qdrant_client.conversions.conversion import GrpcToRest, RestToGrpc + + # `optional uint64 operation_id` carries explicit presence: the server leaves it + # unset for updates that were not assigned a sequence number + absent = grpc.UpdateResult(status=grpc.UpdateStatus.Completed) + assert not absent.HasField("operation_id") + assert GrpcToRest.convert_update_result(absent).operation_id is None + assert not RestToGrpc.convert_update_result(GrpcToRest.convert_update_result(absent)).HasField( + "operation_id" + ) + + # a real operation id of 0 must stay distinguishable from "no operation id" + zero = grpc.UpdateResult(operation_id=0, status=grpc.UpdateStatus.Completed) + assert GrpcToRest.convert_update_result(zero).operation_id == 0 + round_tripped = RestToGrpc.convert_update_result(GrpcToRest.convert_update_result(zero)) + assert round_tripped.HasField("operation_id") + assert round_tripped.operation_id == 0 + + +def test_convert_collection_info_points_count_presence(): + from qdrant_client import grpc + from qdrant_client.conversions.conversion import GrpcToRest, RestToGrpc + + config = grpc.CollectionConfig( + params=grpc.CollectionParams(shard_number=1), + hnsw_config=grpc.HnswConfigDiff(m=16, ef_construct=100, full_scan_threshold=10000), + optimizer_config=grpc.OptimizersConfigDiff(default_segment_number=2), + wal_config=grpc.WalConfigDiff(wal_capacity_mb=32, wal_segments_ahead=0), + ) + + # `optional uint64 points_count` carries explicit presence: an absent count means + # "not available", which is distinct from a collection that holds zero points. + absent = grpc.CollectionInfo( + status=grpc.CollectionStatus.Green, + optimizer_status=grpc.OptimizerStatus(ok=True), + segments_count=1, + config=config, + ) + assert not absent.HasField("points_count") + assert GrpcToRest.convert_collection_info(absent).points_count is None + assert not RestToGrpc.convert_collection_info( + GrpcToRest.convert_collection_info(absent) + ).HasField("points_count") + + zero = grpc.CollectionInfo() + zero.CopyFrom(absent) + zero.points_count = 0 + assert GrpcToRest.convert_collection_info(zero).points_count == 0 + round_tripped = RestToGrpc.convert_collection_info(GrpcToRest.convert_collection_info(zero)) + assert round_tripped.HasField("points_count") + assert round_tripped.points_count == 0