From c38c5e4f88245f0eaef1771fcd5fc09e48d9e90d Mon Sep 17 00:00:00 2001 From: 2sumtech <2sumtech@gmail.com> Date: Sat, 5 Sep 2026 10:00:09 -0700 Subject: [PATCH 1/3] fix(conversion): keep an absent UpdateResult.operation_id as None over gRPC `UpdateResult.operation_id` is declared `optional uint64` in points.proto, so it carries explicit presence. The server leaves it unset for updates that were never assigned a sequence number - a delete-by-filter that matched no points, a clock-rejected update, or a write to a custom-sharded collection that has no shard keys yet. `GrpcToRest.convert_update_result` read the field unconditionally, so the proto default surfaced as `operation_id=0` while the same update over REST (where the key is omitted from the body) yields `operation_id=None`. Since 0 is also a valid operation id, callers could not tell the two apart. Guard the read with `HasField`, matching the presence handling already used for the neighbouring optional fields in this module. --- qdrant_client/conversions/conversion.py | 2 +- tests/conversions/fixtures.py | 10 +++++++++- .../conversions/test_validate_conversions.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/qdrant_client/conversions/conversion.py b/qdrant_client/conversions/conversion.py index 2c64cb2c7..767746bd6 100644 --- a/qdrant_client/conversions/conversion.py +++ b/qdrant_client/conversions/conversion.py @@ -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..0416670d9 100644 --- a/tests/conversions/fixtures.py +++ b/tests/conversions/fixtures.py @@ -1088,6 +1088,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") @@ -2116,7 +2119,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..3c755144b 100644 --- a/tests/conversions/test_validate_conversions.py +++ b/tests/conversions/test_validate_conversions.py @@ -670,3 +670,22 @@ 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 (delete-by-filter that + # matched nothing, clock-rejected updates, custom sharding with no shard keys yet). + 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 From 416b29055a15158b0d912d290514f3aea9f9e82d Mon Sep 17 00:00:00 2001 From: 2sumtech <2sumtech@gmail.com> Date: Tue, 8 Sep 2026 08:29:13 -0700 Subject: [PATCH 2/3] fix(conversion): keep an absent CollectionInfo.points_count as None over gRPC `points_count` is `optional uint64` in collections.proto, so it carries explicit presence and the server may leave it unset when the count is not available. `GrpcToRest.convert_collection_info` read it unconditionally, so `get_collection` over gRPC reported `points_count=0` where the REST client reports `None`, turning "count unavailable" into "collection is empty". The sibling `indexed_vectors_count` on the next line is already guarded with `HasField`. Guard `points_count` the same way. --- qdrant_client/conversions/conversion.py | 2 +- tests/conversions/fixtures.py | 10 ++++++ .../conversions/test_validate_conversions.py | 32 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/qdrant_client/conversions/conversion.py b/qdrant_client/conversions/conversion.py index 767746bd6..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 ), diff --git a/tests/conversions/fixtures.py b/tests/conversions/fixtures.py index 0416670d9..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, @@ -1885,6 +1894,7 @@ "Filter": [filter_nested, filter_], "CollectionInfo": [ collection_info, + collection_info_no_points_count, collection_info_ok, collection_info_red, collection_info_grey, diff --git a/tests/conversions/test_validate_conversions.py b/tests/conversions/test_validate_conversions.py index 3c755144b..62954fd3b 100644 --- a/tests/conversions/test_validate_conversions.py +++ b/tests/conversions/test_validate_conversions.py @@ -689,3 +689,35 @@ def test_convert_update_result_operation_id_presence(): # 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 + + +def test_convert_collection_info_points_count_presence(): + from qdrant_client import grpc + from qdrant_client.conversions.conversion import GrpcToRest, RestToGrpc + from tests.conversions.fixtures import collection_config + + # `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=collection_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( + status=grpc.CollectionStatus.Green, + optimizer_status=grpc.OptimizerStatus(ok=True), + segments_count=1, + config=collection_config, + 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 From 60352e230f9ca29b27d6dad6a71b0cde0d73bbf5 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 10 Sep 2026 23:38:23 +0700 Subject: [PATCH 3/3] tests: extend tests --- .../conversions/test_validate_conversions.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/conversions/test_validate_conversions.py b/tests/conversions/test_validate_conversions.py index 62954fd3b..d7c791ab6 100644 --- a/tests/conversions/test_validate_conversions.py +++ b/tests/conversions/test_validate_conversions.py @@ -677,8 +677,7 @@ def test_convert_update_result_operation_id_presence(): 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 (delete-by-filter that - # matched nothing, clock-rejected updates, custom sharding with no shard keys yet). + # 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 @@ -689,12 +688,21 @@ def test_convert_update_result_operation_id_presence(): # 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 - from tests.conversions.fixtures import collection_config + + 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. @@ -702,7 +710,7 @@ def test_convert_collection_info_points_count_presence(): status=grpc.CollectionStatus.Green, optimizer_status=grpc.OptimizerStatus(ok=True), segments_count=1, - config=collection_config, + config=config, ) assert not absent.HasField("points_count") assert GrpcToRest.convert_collection_info(absent).points_count is None @@ -710,13 +718,9 @@ def test_convert_collection_info_points_count_presence(): GrpcToRest.convert_collection_info(absent) ).HasField("points_count") - zero = grpc.CollectionInfo( - status=grpc.CollectionStatus.Green, - optimizer_status=grpc.OptimizerStatus(ok=True), - segments_count=1, - config=collection_config, - points_count=0, - ) + 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")