Skip to content

Preserve proto3 field presence for falsy values in REST/gRPC conversion - #1260

Merged
joein merged 5 commits into
qdrant:devfrom
winklemad:fix/rest-grpc-falsy-field-presence
Jul 23, 2026
Merged

joein merged 5 commits into
qdrant:devfrom
winklemad:fix/rest-grpc-falsy-field-presence

Conversation

@winklemad

Copy link
Copy Markdown
Contributor

No linked issue — found by reading the REST↔gRPC conversion code.

Summary

Three conversions in qdrant_client/conversions/conversion.py lose information for values that are legitimately falsy (0, 0.0, False, or an unset optional). In each case a falsy-but-present value ends up indistinguishable from "missing", which changes the meaning of the request/response.

Bugs

1. shard_key / order_value of 0 dropped (REST → gRPC)

convert_scored_point and convert_record guarded these with a plain truthiness check:

shard_key=cls.convert_shard_key(model.shard_key) if model.shard_key else None,
order_value=cls.convert_order_value(model.order_value) if model.order_value else None,

An integer shard key of 0 or an order value of 0 / 0.0 is a valid value, but if model.shard_key is False for it, so the field never made it into the gRPC message. Switched to is not None.

2. IntegerIndexParams.range / .lookup unset → False instead of None (gRPC → REST)

range=model.range,
lookup=model.lookup,

Both are optional bool fields, so reading them without a HasField guard returns the proto default False when they were never set — turning "unspecified" into an explicit False and silently overriding the server-side default. Every sibling field in the same struct (is_principal, on_disk, enable_hnsw) already guards with HasField; range/lookup now match.

3. SparseVectorParams.index fabricated when unset (gRPC → REST)

index=(
    cls.convert_sparse_index_config(model.index)
    if model.HasField("index") is not None
    else None
),

HasField(...) returns a bool, and bool is not None is always True, so the guard never short-circuits. When no index was set, model.index is a default-constructed empty message and an empty SparseIndexParams(full_scan_threshold=None, on_disk=None, datatype=None) was produced instead of None. Dropped the dead is not None.

Tests

Added three focused tests in tests/conversions/test_validate_conversions.py:

  • test_convert_point_falsy_shard_key_and_order_value — 0 shard key / order value survive both ScoredPoint and Record, and None still maps to an unset field.
  • test_convert_integer_index_params_optional_flags — unset range/lookup → None, explicit True/False preserved.
  • test_convert_sparse_vector_params_without_index — no index → None, set index still converts.

Full tests/conversions/test_validate_conversions.py passes (24 tests), and ruff format --line-length 99 (0.4.3) is clean.

@netlify

netlify Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit 2e944db
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a625e3c76b4e20008b50334
😎 Deploy Preview https://deploy-preview-1260--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 Jul 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@joein, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3af0b6fd-cc14-449a-8c5f-f668dc18245e

📥 Commits

Reviewing files that changed from the base of the PR and between 8464339 and 2e944db.

📒 Files selected for processing (1)
  • tests/conversions/fixtures.py
📝 Walkthrough

Walkthrough

Updated REST↔gRPC conversions to distinguish unset optional fields from explicit values, preserving falsy shard_key and order_value values. Expanded conversion fixtures with empty integer and sparse-vector parameters, additional scored/retrieved point variants, and updated fixture registries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preserving falsy field presence during REST/gRPC conversion.
Description check ✅ Passed The description directly explains the conversion bugs, fixes, and tests in this changeset.
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.
✨ 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.

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran all three of these against master rather than reading the diff, and they check out. Then went looking for the same pattern elsewhere in the file and found eight more instances the PR does not touch, on the write path.

Verification of what is here.

######## master ########
scored: HasField(shard_key)=False HasField(order_value)=False
record: HasField(shard_key)=False HasField(order_value)=False
unset IntegerIndexParams -> range=False lookup=False is_principal=True
SparseVectorParams()     -> index=SparseIndexParams(full_scan_threshold=None, on_disk=None, datatype=None)

######## this branch ########
scored: HasField(shard_key)=True  HasField(order_value)=True
record: HasField(shard_key)=True  HasField(order_value)=True
unset IntegerIndexParams -> range=None lookup=None is_principal=True
SparseVectorParams()     -> index=None

The round trip argument is the cleanest way to see why 2 and 3 matter. grpc -> rest -> grpc on master:

IntegerIndexParams(is_principal=True)  roundtrip equal: False
IntegerIndexParams()                   roundtrip equal: False
SparseVectorParams()                   roundtrip equal: False

on this branch:

IntegerIndexParams(is_principal=True)  roundtrip equal: True
IntegerIndexParams(range=True, lookup=False) roundtrip equal: True
IntegerIndexParams()                   roundtrip equal: True
SparseVectorParams()                   roundtrip equal: True
SparseVectorParams(index=...on_disk=True)    roundtrip equal: True

So these are not cosmetic, master genuinely cannot round trip an unset range/lookup or an absent sparse index. HasField("range") not raising also confirms both are declared optional in the proto, which was the thing I most expected to be wrong.

The HasField("index") is not None line is a nice find, HasField returns a plain bool so that guard was unconditionally true and every SparseVectorParams without an index came back carrying a fabricated all-None SparseIndexParams.

All three new tests fail on master with only conversion.py reverted, so they are not just encoding the new behavior:

FAILED test_convert_point_falsy_shard_key_and_order_value
FAILED test_convert_integer_index_params_optional_flags
FAILED test_convert_sparse_vector_params_without_index

Full tests/conversions/test_validate_conversions.py is 24 passed on the branch. Worth noting the fix also covers shard_key="", since ShardKey = Union[StrictInt, StrictStr] and the empty string is equally falsy. I checked, it survives on the branch and is dropped on master. Might be worth an assertion, it is free.

The gap. RestToGrpc.convert_points_update_operation has the identical truthiness guard in eight places:

4598:  if model.upsert.shard_key
4622:  if model.delete.shard_key
4643:  if model.set_payload.shard_key
4667:  if model.overwrite_payload.shard_key
4691:  if model.delete_payload.shard_key
4705:  if model.clear_payload.shard_key
4719:  if model.update_vectors.shard_key
4748:  if model.delete_vectors.shard_key

Still reproduces on this branch:

UpsertOperation          shard_key=0 survives? False    shard_key=7 survives? True
DeleteOperation          shard_key=0 survives? False    shard_key=7 survives? True
ClearPayloadOperation    shard_key=0 survives? False    shard_key=7 survives? True

This one bothers me more than the two the PR does fix. convert_scored_point and convert_record are response-shaped, so a dropped shard_key mostly costs the caller some metadata. convert_points_update_operation feeds batch_update_points, so an integer shard key of 0 silently loses its routing and the write lands wherever the server puts an unspecified selector. Silent misrouting on a write is a worse failure mode than a missing field on a read.

The correct idiom is already in the file, about 470 lines above, in convert_query_request:

shard_key_selector=(
    cls.convert_shard_key_selector(model.shard_key) if model.shard_key is not None else None
),

so those eight are just stragglers that never got updated. Same one-token change you already made twice. (ShardKeySelector also admits List[ShardKey], so an empty list is falsy at those sites too, though that input is degenerate enough that I would not worry about it.)

I would rather see them in this PR than in a follow-up, since the PR is already "audit the falsy guards in conversion.py" and leaving half the audit undone makes the next person assume it was finished. Everything already in the diff looks right to me.

@winklemad

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed verification and for tracing the same pattern through the write path. I reproduced all eight convert_points_update_operation cases locally and confirmed that shard_key=0 is dropped by the truthiness guards. Since #1279 now covers those eight sites with regression coverage for both 0 and "", I’ll keep this PR focused on the three conversion fixes already here to avoid duplicating or conflicting with that work.

@joein
joein self-requested a review July 23, 2026 16:47
@joein

joein commented Jul 23, 2026

Copy link
Copy Markdown
Member

Hey @winklemad
Thanks for pointing this out!
I'll check whether the bugs exist and then review the PR.
Thanks @ErenAta16 for the review

winklemad and others added 2 commits July 24, 2026 01:25
Three spots in conversions/conversion.py lost information for legitimately
falsy values when converting between the REST and gRPC models:

- ScoredPoint/Record: a shard_key of 0 or an order_value of 0/0.0 was
  dropped by a truthiness check (`if model.shard_key`), so it never made it
  into the gRPC message. Check `is not None` instead.
- IntegerIndexParams: `range` and `lookup` are optional bools but were read
  without a HasField guard, so an unset field turned into False instead of
  None, silently overriding the server-side default.
- SparseVectorParams: the index guard was `HasField("index") is not None`,
  which is always true since HasField returns a bool, so an empty
  SparseIndexParams was fabricated even when no index was configured.

Added regression tests covering all three.
@joein
joein force-pushed the fix/rest-grpc-falsy-field-presence branch from a3a3159 to 8464339 Compare July 23, 2026 18:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/conversions/fixtures.py`:
- Around line 518-533: Update the order-value fixture variants in the conversion
fixtures so falsy-value cases explicitly use grpc.OrderValue(int=0), and ensure
the float variants provide an explicit zero-valued order value rather than
omitting the field. Apply this consistently to both ScoredPoint and
RetrievedPoint fixtures.
- Line 633: Update the integer-index test data to use integer_index_params_2 in
payload_schema_integer_default and include integer_index_params_2 in the
IntegerIndexParams fixture list. Preserve the existing fixtures while ensuring
the unset-presence case for the optional range and lookup fields is exercised.
- Around line 534-556: Update the four fixtures retrieved_point,
retrieved_point_order_value_int, retrieved_point_order_value_float, and
retrieved_point_multivector to construct grpc.RetrievedPoint instead of
grpc.ScoredPoint. Apply the same type correction to the corresponding fixtures
around lines 1766-1772, preserving their existing fields and values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10b84b77-36f1-4157-b2b6-c4145f1b42a9

📥 Commits

Reviewing files that changed from the base of the PR and between a4db08d and a3a3159.

📒 Files selected for processing (1)
  • tests/conversions/fixtures.py

Comment on lines +518 to +533
scored_point_shard_key_order_value_falsy_value = grpc.ScoredPoint(
id=point_id,
payload=payload,
score=0.99,
vectors=single_vector_output,
version=12,
shard_key=grpc.ShardKey(number=0),
order_value=grpc.OrderValue(int=3),
)
retrieved_point_shard_key_order_value_falsy_value = grpc.RetrievedPoint(
id=point_id,
payload=payload,
vectors=single_vector_output,
shard_key=grpc.ShardKey(number=0),
order_value=grpc.OrderValue(int=3),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant fixture references.
sed -n '500,560p' tests/conversions/fixtures.py

# Search for order_value fixture expectations/usages.
rg -n "order_value|OrderValue|retrieved_point_order_value|scored_point_order_value" tests/conversions fixtures tests -S || true

# Lightweight check for the declared values and whether comments are present.
python3 - <<'PY'
from pathlib import Path
p = Path('tests/conversions/fixtures.py')
lines = p.read_text().splitlines()
for i in range(510, 560):
    if i < len(lines) and ('retrieved_point_order_value' in lines[i-1] or 'scored_point_order_value' in lines[i-1] or 'OrderValue' in lines[i-1]):
        print(f"{i}: {lines[i-1]}")
PY

Repository: qdrant/qdrant-client

Length of output: 6680


Use actual order-value inputs in the order-value fixture variants.

The *_order_value_falsy_value fixtures pass grpc.OrderValue(int=3), so they do not exercise 0. The *_order_value_float fixtures omit the order_value field entirely, so they do not exercise integer-false ordering. Add explicit zero-valued fixtures for both int and float where coverage is needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conversions/fixtures.py` around lines 518 - 533, Update the order-value
fixture variants in the conversion fixtures so falsy-value cases explicitly use
grpc.OrderValue(int=0), and ensure the float variants provide an explicit
zero-valued order value rather than omitting the field. Apply this consistently
to both ScoredPoint and RetrievedPoint fixtures.

Comment thread tests/conversions/fixtures.py Resolved
integer_index_params_1 = grpc.IntegerIndexParams(
lookup=True, range=True, on_disk=True, is_principal=True, enable_hnsw=True
)
integer_index_params_2 = grpc.IntegerIndexParams()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate fixture file =="
fd -a 'fixtures.py$' . | sed 's#^\./##'

echo "== inspect relevant fixture lines =="
sed -n '600,715p' tests/conversions/fixtures.py

echo "== search integer_index_params registry usages =="
rg -n 'integer_index_params_[0-9]|payload_schema_integer_default|IntegerIndexParams|integer.*lookup|integer.*range' tests/conversions/fixtures.py tests/conversions -S || true

Repository: qdrant/qdrant-client

Length of output: 6289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== proto files around IntegerIndexParams =="
fd -a '\.proto$' . | sed 's#^\./##' | head -50
rg -n 'message IntegerIndexParams|optional bool lookup|bool lookup|optional bool range|bool range' . -S --glob '*.proto'

echo "== fixture exports involving integer schema and registry =="
sed -n '740,810p' tests/conversions/fixtures.py
sed -n '1770,1810p' tests/conversions/fixtures.py

echo "== python/pytest availability =="
python3 - <<'PY'
print('python available')
import grpc
print('grpc available, version', getattr(grpc, '__version__','?'))
PY || true
python3 - <<'PY'
try:
    from qdrant_client.http import api
    print('qdrant_client.http.api available')
except Exception as e:
    print('qdrant_client.http.api unavailable:', type(e).__name__, str(e))
PY || true

Repository: qdrant/qdrant-client

Length of output: 5599


Wire the empty integer-index fixture into the test data.

integer_index_params_2 is defined as the empty/default fixture, but payload_schema_integer_default still uses integer_index_params_0, and the IntegerIndexParams fixture list omits _2. Since range and lookup are separate optional proto fields, the unset-presence case is not covered unless this fixture is referenced in both places.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conversions/fixtures.py` at line 633, Update the integer-index test
data to use integer_index_params_2 in payload_schema_integer_default and include
integer_index_params_2 in the IntegerIndexParams fixture list. Preserve the
existing fixtures while ensuring the unset-presence case for the optional range
and lookup fields is exercised.

@joein
joein merged commit 69a5c22 into qdrant:dev Jul 23, 2026
8 of 12 checks passed
joein added a commit that referenced this pull request Aug 4, 2026
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.

3 participants