Skip to content

fix(conversion): keep an absent UpdateResult.operation_id as None over gRPC - #1408

Open
2sumtech wants to merge 1 commit into
qdrant:devfrom
2sumtech:fix/grpc-update-result-operation-id-presence
Open

fix(conversion): keep an absent UpdateResult.operation_id as None over gRPC#1408
2sumtech wants to merge 1 commit into
qdrant:devfrom
2sumtech:fix/grpc-update-result-operation-id-presence

Conversation

@2sumtech

@2sumtech 2sumtech commented Sep 5, 2026

Copy link
Copy Markdown

All Submissions:

  • Contributions should target the dev branch. Did you create your branch from dev?
  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?

Changes to Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

What

UpdateResult.operation_id is declared with explicit presence in points.proto:

message UpdateResult {
  // Number of operation
  optional uint64 operation_id = 1;
  // Operation status
  UpdateStatus status = 2;
}

GrpcToRest.convert_update_result read it unconditionally, so an absent field
came back as the proto default 0 instead of None:

operation_id=model.operation_id,          # before
operation_id=model.operation_id if model.HasField("operation_id") else None,   # after

Every neighbouring optional field in this module is already presence-guarded
(indexed_vectors_count, params, update_queue, …); this one was the odd one out.

Why it matters

The server really does leave operation_id unset. In qdrant, UpdateResult.operation_id
is Option<SeqNumberType> and is returned as None from at least three paths:

  • lib/collection/src/shards/replica_set/mod.rs — delete-by-filter that matched no points
  • lib/collection/src/shards/local_shard/shard_ops.rsSubmitOutcome::ClockRejected
  • lib/storage/src/content_manager/toc/point_ops.rs — custom sharding with no shard keys yet

On the REST side the field is annotated #[serde(skip_serializing_if = "Option::is_none")],
so the key is simply missing from the body and pydantic yields None. Over gRPC the same
update yielded operation_id=0. Because 0 is itself a valid operation id (the first
operation in a fresh collection), callers had no way to tell "no operation id" from
"operation 0", and prefer_grpc=True silently disagreed with prefer_grpc=False.

Reproduction and evidence (no server required)

repro.py — the two shapes the client receives for an update that got no sequence number:

from qdrant_client import grpc
from qdrant_client.conversions.conversion import GrpcToRest
from qdrant_client.http.models import models as rest

rest_body = {"status": "completed"}                                # HTTP response body
grpc_msg = grpc.UpdateResult(status=grpc.UpdateStatus.Completed)   # gRPC message

print("REST :", rest.UpdateResult(**rest_body))
print("gRPC :", GrpcToRest.convert_update_result(grpc_msg))

Before the fix:

REST : operation_id=None status=<UpdateStatus.COMPLETED: 'completed'>
gRPC : operation_id=0 status=<UpdateStatus.COMPLETED: 'completed'>

After the fix:

REST : operation_id=None status=<UpdateStatus.COMPLETED: 'completed'>
gRPC : operation_id=None status=<UpdateStatus.COMPLETED: 'completed'>

Tests fail before, pass after. Two independent checks cover it: the new
update_result_no_operation_id fixture (which flows through the existing
test_conversion_completeness grpc→rest→grpc harness) and a focused unit test.

On the unpatched tree, with the new tests in place:

$ python -m pytest tests/conversions/test_validate_conversions.py -q
...
>       assert GrpcToRest.convert_update_result(absent).operation_id is None
E       AssertionError: assert 0 is None
E        +  where 0 = UpdateResult(operation_id=0, status=<UpdateStatus.COMPLETED: 'completed'>).operation_id

FAILED tests/conversions/test_validate_conversions.py::test_conversion_completeness
FAILED tests/conversions/test_validate_conversions.py::test_convert_update_result_operation_id_presence
2 failed, 22 passed in 0.94s

With the fix:

$ python -m pytest tests/conversions/test_validate_conversions.py -q
........................                                                 [100%]
24 passed in 0.65s

Surrounding server-free suites are unaffected:

$ python -m pytest tests/conversions tests/test_in_memory.py tests/test_local_persistence.py tests/test_common.py -q
........................................................                 [100%]
56 passed in 0.90s

(The rest of tests/ needs a live Qdrant instance, which was not available in this
environment; nothing outside conversions/ is touched by this change.)

ruff-format --line-length=99 is clean on all three touched files.

How it was found

A static probe over GrpcToRest: for every converter whose first argument is annotated
grpc.<Message>, cross-reference the proto descriptor's explicit-presence scalar fields
against HasField(...) guards in the function body. Seven converters read an
explicit-presence scalar unguarded; five were false positives (oneof variants read via
WhichOneof, or fields whose REST counterpart is a required int that cannot hold None).
UpdateResult.operation_id was the one where the REST counterpart is Optional[int] and
the server has real code paths that omit the field.

Disclosure: prepared with AI assistance (Claude Code); I reviewed the change and take responsibility for it.

…r 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.
@netlify

netlify Bot commented Sep 5, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit c38c5e4
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a9c4b778d68e00008d81dd9
😎 Deploy Preview https://deploy-preview-1408--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 174b1f79-28ee-4934-aba4-1f2c3f1510ef

📥 Commits

Reviewing files that changed from the base of the PR and between e5e26b8 and c38c5e4.

📒 Files selected for processing (3)
  • qdrant_client/conversions/conversion.py
  • tests/conversions/fixtures.py
  • tests/conversions/test_validate_conversions.py

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

GrpcToRest.convert_update_result now checks whether the optional operation_id field is set. An unset field converts to None, while an explicit value such as 0 remains unchanged. New fixtures and tests validate absent-field handling and round-trip conversion without setting the protobuf field.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to c38c5

gRPC update results now preserve an omitted operation ID as None while retaining explicit zero values. The conversion and round-trip coverage support the intended behavior, with no remaining merge-blocking risk.

Suggested reviewers: joein

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the gRPC conversion bug, the HasField fix, the reason for the change, and the test coverage.
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving an absent UpdateResult.operation_id as None during gRPC conversion.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant