Skip to content

GG-50943 Support vector storage, segment parameters and INT8 on the thin client - #83

Merged
PakhomovAlexander merged 6 commits into
masterfrom
gg-50943
Sep 1, 2026
Merged

GG-50943 Support vector storage, segment parameters and INT8 on the thin client#83
PakhomovAlexander merged 6 commits into
masterfrom
gg-50943

Conversation

@PakhomovAlexander

@PakhomovAlexander PakhomovAlexander commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

GG-50943

Part of the GG-50943 fan-out. The Java side of this ticket added the per-index segment
parameters (bit 41) and, during self-review, gave INT8 storage a bit of its own (bit 42). The
Python client knew bits 33 and 38 and stopped there, so none of the vector index storage
parameters were reachable from Python at all.

What was missing

bit feature added by
39 QUERY_INDEX_VECTOR_QUANTIZATION GG-49429, never wired into this client
40 CONTINUOUS_QUERY_COMPACT_VALUE — not ours GG-37685
41 QUERY_INDEX_VECTOR_SEGMENT_PARAMS GG-50943
42 QUERY_INDEX_VECTOR_INT8_STORAGE GG-50943

Bit 39 belongs to GG-49429 rather than this ticket. It is here because bits 40 and 41 are
unusable without it: INT8 is a value on the field bit 39 adds, so a client that cannot send
quantization cannot send INT8 either. Splitting them would land a feature that nothing can reach.

Why bit 42 is not a field

INT8 was appended to an enum that had already shipped behind bit 39. A peer with bit 39 reads
the ordinal and resolves a value it does not know to ENGINE_DEFAULT — so an INT8 request against
such a peer returns success and builds a full-precision index, and the only symptom is a recall
number nobody is measuring. The Java thin client refuses it before it goes out
(ClientUtils.cacheConfiguration throws ClientFeatureNotSupportedByServerException when the peer
lacks bit 42, in org.apache.ignite.internal.client.thin); this client refuses it too, rather than
sending an ordinal it knows will be misread. Neither refusal is the server's: the server reader gates
on bit 39 alone, which is the accepted read-back limitation below.

BINARY still reaches a peer without bit 42. That is the case a naive guard breaks — gate every
quantization on bit 42 and the refusal test passes while every existing BINARY caller stops
working — so there is a test in each direction.

Refusals, not silent drops

The existing HNSW code already refuses a parameter the wire cannot carry instead of dropping it,
on the grounds that a dropped parameter is invisible until somebody measures recall. The new
parameters follow it, with three distinct failures: a parameter on a non-VECTOR index
(ValueError), a parameter the cluster never negotiated (NotSupportedByClusterError), and INT8
against a cluster that has the field but not the value.

A memoisation hazard this change would have introduced

query_entities_struct is lru_cached on its argument tuple. Once the new parameters had
defaults, (True, True) and (True, True, False, False, False) became two cache entries for one
feature set — so the cache config struct and the query entities property would each get their own
layout object instead of sharing one. Both entry points now normalise to a single five-argument
key before the cache.

The pre-existing test_layout_is_shared_between_config_and_property caught this, which is exactly
what an identity assertion is for. Worth noting for anyone extending the layout again: adding a
defaulted parameter to a memoised layout function is enough to desynchronise the two paths.

Verification

  • 26 new offline tests in tests/test_query_index_vector_storage_params.py: per-bit byte-level
    layout deltas (+4 for quantization, +8 for the segment pair, +12 for both), bit 42 adding no
    bytes, the new fields never reaching a non-VECTOR index for any of the three non-vector types,
    value round trips, unset values round-tripping as the engine default, and every refusal with its
    negative control.
  • Ordinals and ceilings are pinned against the server's own values (INT8 == 3,
    MAX_VECTOR_INDEX_SEGMENTS == 1024, MAX_VECTOR_QUERY_THREADS == 64), because the wire carries
    the ordinal and a drift there is a silently different storage mode.
  • Full offline suite: 152 passed, 1 skipped. flake8 clean on every changed file.

Not covered here: end-to-end behaviour against a live cluster, which needs the EE vector-query
module and a vector-search licence. This client is not covered by MTCGA either — its CI is the
external Jenkins/tox run.

Renumbered 2026-08-27

master commit fe1325fa8bf (GG-37685) took protocol bit 40 for
CONTINUOUS_QUERY_COMPACT_VALUE while this work held it. master shipped first and keeps 40, so
the vector bits moved up: QUERY_INDEX_VECTOR_SEGMENT_PARAMS is 41 and
QUERY_INDEX_VECTOR_INT8_STORAGE is 42. Bit 40 is not a vector bit, so the gap between 39
and 41 is deliberate. master is merged in, and the branch is 0 commits behind.

…hin client

The client knew feature bits 33 and 38 and stopped there, so three server-side
vector index parameters were unreachable from Python: quantization (bit 39),
the segment target and query-thread count (bit 40), and INT8 storage (bit 41).

Bit 41 is not a field. INT8 is a value on the enum bit 39 already carried, so a
peer with bit 39 reads the ordinal and resolves a value it does not know to the
engine default -- the caller asks for byte codes and silently gets a
full-precision index. The server gave INT8 its own bit for exactly that, and this
client refuses an INT8 request the peer cannot honour rather than sending it.
BINARY still reaches a peer without bit 41, which is the case a naive guard
breaks; there is a test for each direction.

The refusals follow the shape already here for the HNSW parameters: a parameter
the wire cannot carry is an error, never dropped, because a dropped parameter is
invisible until somebody measures recall.

Also fixes a memoisation hazard this change would otherwise have introduced.
query_entities_struct is lru_cached on its argument tuple, so once the parameters
had defaults, (True, True) and (True, True, False, False, False) became two cache
entries for one feature set -- and the cache config struct and the query entities
property would each get their own layout instead of sharing one. Both entry
points now normalise to a single five-argument key. The pre-existing identity
test caught this, which is what it was written for.

26 new offline wire tests: byte-level layout deltas per bit, INT8 adding no
bytes, the new fields never reaching a non-VECTOR index, round trips, and every
refusal with its negative control. Full offline suite 152 passed, flake8 clean.
Audited the six the server declares in ClientBitmaskFeature -- 33 similarity,
35 extended vector query, 38 HNSW parameters, 39 quantization, 40 segment
parameters, 41 INT8 -- against this client. All six are now declared, each has a
protocol-context accessor, and each is honoured: 33 and 38 through the index
layout, 35 through ef_search/with_scores/no_content on the query API, and 39, 40
and 41 through this change.

Three tests make that a property rather than a coincidence. The server's list is
a literal here because this client cannot read the server's enum, so the list IS
the contract and a seventh bit has to be reflected deliberately. One test pins the
declared set against it, one requires an accessor for every declared bit, and one
requires each to be advertised by all_supported().

The accessor test is the one that matters: a declared bit is an advertised bit,
so the server may send a payload for it. Declaring a bit with no way to ask
whether it was negotiated is a promise the client cannot keep, and it fails on
the wire instead of in the suite.

Offline suite 160 passed, flake8 clean.
@PakhomovAlexander

PakhomovAlexander commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Superseded 2026-08-27. The bit numbers below are the old ones. master took bit 40 for
CONTINUOUS_QUERY_COMPACT_VALUE, so segment params moved to 41 and INT8 to 42. The
whole check was re-run at the new numbers against a node built from the current heads. See
the later comment on this PR.

Tested end to end against a real node

The PR body said end-to-end needed a live cluster and that I had not done it. Now done, against a
licensed GG8 node built from gg-50943 (CE ec60764ef60, EE f0cc5a3785e) serving thin clients on
10800. Every check passed.

1. The bits are actually negotiated, not just declared.

bit 35 QUERY_VECTOR_EXTENDED             negotiated
bit 39 QUERY_INDEX_VECTOR_QUANTIZATION   negotiated
bit 40 QUERY_INDEX_VECTOR_SEGMENT_PARAMS negotiated
bit 41 QUERY_INDEX_VECTOR_INT8_STORAGE   negotiated

2. The server accepts all three new parameters from this client. A cache created from Python with
a VECTOR index carrying quantization=INT8, max_segments=4, query_threads=2 started cleanly.

3. They survive a real round trip. Reading the configuration back off the server:

{'quantization': 3, 'max_segments': 4, 'query_threads': 2}

3 is VectorQuantization.INT8. So the ordinal this client writes is the one the server stores and
reports, which is the thing the offline byte tests could only assert against themselves.

4. The INT8 index is searchable, and the bit-35 options work on it. 20 documents inserted, then:

check result
vector query over the INT8 index 3 rows for k=3
ef_search=64 accepted, 3 rows
with_scores (0, Doc(TITLE='doc-0', ...), 1.0) — score per row, 1.0 for the exact match
no_content [0, 8, 16] — bare keys, no values

That last group matters beyond the flags: the search runs over an index the engine actually built
as INT8
, so the parameter did more than round-trip cosmetically.

Two things worth recording

The first attempt failed with Failed to create vector index. Only REPLICATED cache mode is supported. That is an engine constraint rather than a client problem, but the second attempt failed
the same way after setting PROP_CACHE_MODE, which sent me looking in the wrong place. The cause
was stale state: the first, mode-less attempt left a cache descriptor registered as PARTITIONED, and
the check is ctx.cache().cacheMode(cacheName) at index-creation time. A fresh cache name passed
immediately. Anyone testing vector caches interactively will hit this.

I have not added this as a committed test. It needs a licensed node with the EE vector-query
module, which this repository's CI does not have, so it would be a test nobody runs — and a check
that never runs is worse than a documented manual procedure. The offline suite (160 passed) remains
the automated coverage; this comment is the record of the live verification.

The server took bit 40 for CONTINUOUS_QUERY_COMPACT_VALUE in master
commit fe1325fa8bf, after this client claimed 40 and 41 for the vector
parameters. The server keeps 40, so this client follows:

  QUERY_INDEX_VECTOR_SEGMENT_PARAMS  40 -> 41
  QUERY_INDEX_VECTOR_INT8_STORAGE    41 -> 42

The SERVER_VECTOR_BITS guard is what makes this safe to change: it holds
the server's bit numbers as a literal, so the assertion moves with the
enum and a client that drifts from the server fails the test.

The docstring now says bit 40 belongs to a non-vector feature, so the
gap between 39 and 41 is deliberate. Nobody should close it.

168 offline tests pass, 1 skipped. flake8 clean on both files.
@PakhomovAlexander

Copy link
Copy Markdown
Contributor Author

Re-run end to end after the bit renumber

The earlier end-to-end run negotiated bits 40 and 41. Those numbers are gone: master commit
fe1325fa8bf (GG-37685) took bit 40 for CONTINUOUS_QUERY_COMPACT_VALUE, so this client now uses
41 for segment params and 42 for INT8. A proof at the old numbers proves nothing about the
new ones, so the whole check was re-run.

Node built from the current heads — CE 19e9d1f59ca, EE 14531dcbe7e — licensed, thin clients on
10800. Client at 1b54f91.

1. The server negotiates the new numbers. This is the check that matters after a renumber: it
fails if either enum moved without the other.

bit 35 QUERY_VECTOR_EXTENDED             negotiated
bit 39 QUERY_INDEX_VECTOR_QUANTIZATION   negotiated
bit 41 QUERY_INDEX_VECTOR_SEGMENT_PARAMS negotiated
bit 42 QUERY_INDEX_VECTOR_INT8_STORAGE   negotiated

2. All three parameters still reach the server and come back. A VECTOR index created from
Python with quantization=INT8, max_segments=4, query_threads=2 started cleanly, and reading
the configuration back gave {'quantization': 3, 'max_segments': 4, 'query_threads': 2}. 3 is
VectorQuantization.INT8.

3. The INT8 index is searchable, and the bit-35 options work on it.

check result
vector query over the INT8 index 3 rows for k=3
ef_search=64 accepted, 3 rows
with_scores (0, Doc(TITLE='doc-0', ...), 1.0) — 1.0 for the exact match
no_content [0, 8, 16] — bare keys, no values

Every check passed. The offline suite is 168 passed, 1 skipped, and flake8 is clean on both
changed files.

This stays a manual procedure rather than a committed test, for the same reason as before: it needs
a licensed node with the EE vector-query module, which this repository's CI does not have, so it
would be a test nobody runs. The SERVER_VECTOR_BITS literal in
tests/test_query_index_vector_storage_params.py is the automated half — it holds the server's bit
numbers as data, so a client that drifts from the server fails offline.

@ivanzlenko ivanzlenko 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.

Verified the protocol work against the Java side (gridgain#4009 fetched locally), not just this description. Bits 39/41/42, the 0..3 quantization ordinals, the 1024/64 ceilings, and the wire order (quantization int, then maxSegments, queryThreads) all match ClientBitmaskFeature, VectorQuantization, QueryIndex and PlatformConfigurationUtils.writeQueryIndex on that branch. The gap at bit 40 is correct, and correctly not declared here. The Java PR title's "deleted-vector budget" is a system property, not a per-index wire field, so nothing is missing from the layout.

Ran it: 151 passed, 18 skipped, flake8 clean on all four changed files. Also hand-checked a mixed [SORTED, VECTOR, SORTED] array round-trips without stream desync.

Five comments below. Only the merge-ordering one should gate the merge; the other two code points are one-line fixes.

Also confirmed as not problems, since both look like they could be:

  • The query_entities_struct normalisation is load-bearing, not defensive padding. cache_config.py:436 still calls it with two arguments, and without the wrapper f(True, False) is f(True, False, False, False, False) is False.
  • Excluding has_int8 from the layout gate in _prop_query_entities_cached leaves no hole. Checked all three: the two int8 variants are distinct classes with identical bytes, and int8-without-quantization still refuses INT8 via the quantization_supported branch.

Comment thread pygridgain/datatypes/cache_config.py Outdated
Comment thread pygridgain/datatypes/cache_config.py Outdated
Comment thread pygridgain/datatypes/cache_config.py
QUERY_VECTOR_EXTENDED = 1 << 35
QUERY_INDEX_VECTOR_HNSW_PARAMS = 1 << 38
QUERY_INDEX_VECTOR_QUANTIZATION = 1 << 39
QUERY_INDEX_VECTOR_SEGMENT_PARAMS = 1 << 41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merge ordering. origin/master ClientBitmaskFeature still ends at CONTINUOUS_QUERY_COMPACT_VALUE(40); — 41 and 42 exist only on the unmerged gridgain#4009 (state: OPEN).

Until that lands this client advertises two bits no released server has, and #4009 has already renumbered once when master took 40. SERVER_VECTOR_BITS is a literal, so a second renumber would not be caught here. Land this after #4009.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still open — nothing to fix in this repo. Re-checked just now: gridgain#4009 is state=OPEN, mergedAt=null, and origin/master ClientBitmaskFeature still ends at CONTINUOUS_QUERY_COMPACT_VALUE(40);. Bits 41 and 42 exist only on that branch.

Leaving this thread open as the merge gate. Nothing else blocks.

Comment thread tests/test_query_index_vector_storage_params.py
From ivanzlenko's review of this PR.

 - str.capitalize() lowercases the tail, so the refusal read "Per-index
   hnsw build parameters" where master read "HNSW". The callers now pass
   sentence-cased text and the format string leaves it alone. The
   cluster-side message lower-cases the first word again, because there
   it reads mid-sentence. Neither pin caught this: both match only
   "VECTOR indexes only".
 - The wire-order comment cited ClientUtils, which is the Java thin
   client. The writer this layout has to match is the server's
   PlatformConfigurationUtils.writeQueryIndex.
 - An ordinal outside VectorQuantization now fails here instead of
   reaching the server. VectorQuantization is an Int subclass, not an
   Enum, so it cannot be iterated; VECTOR_QUANTIZATIONS names the set.
 - test_async_path_matches_the_sync_path, following the precedent the
   HNSW ticket set. from_python_async and parse_async are hand-written
   twins of the sync path, so parity is worth pinning.

169 offline tests pass, 1 skipped. flake8 clean on the changed files.

@ivanzlenko ivanzlenko 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.

Re-reviewed 0f57b5c. Four of the five are fixed and verified — resolved those threads with the evidence on each.

  • Casing: .capitalize() gone, acronym survives both branches. The NotSupported message is byte-identical to master's wording.
  • Citation: now names PlatformConfigurationUtils.writeQueryIndex / ClientCacheConfigurationSerializer.
  • Unknown ordinal: refused client-side; 0/1/2/3 all still serialize, INT8-without-bit-42 unchanged.
  • Async: parity test passes over INT8 plus both segment fields, byte-equal to the sync writer.

Suite 152 passed, 18 skipped (up one), flake8 clean on all four changed files.

Bit 41/42 thread stays open — gridgain#4009 is still state=OPEN, mergedAt=null and master's ClientBitmaskFeature still ends at CONTINUOUS_QUERY_COMPACT_VALUE(40);. Nothing to fix here; it is the merge gate.

Two small things the fix commit introduced, below. Neither blocks.

Comment thread tests/test_query_index_vector_storage_params.py
Comment thread pygridgain/datatypes/cache_config.py
…rder

ivanzlenko's re-review found the unknown-ordinal guard was the only
refusal in the PR without a test, and that its position was load-bearing
and untested: it fired before the index-type check, so a SORTED index
with quantization=7 reported "Unknown vector storage mode" when the
value is irrelevant on a non-VECTOR index.

The order is now: engine default returns, a non-VECTOR index is refused
by its type, an unknown ordinal is refused before the feature bits so
garbage input is never reported as a cluster capability gap.

Three tests pin it. The refusal itself, mutation-checked (disabling the
membership check fails it). A loop over all four known ordinals as the
negative control, because a membership check is easy to write so it
rejects something valid. And the precedence case.

VECTOR_QUANTIZATIONS is _VECTOR_QUANTIZATIONS now, not an __all__ entry.
It exists only because VectorQuantization is an Int subclass that cannot
be iterated, and a public name would outlive that workaround.

175 offline tests pass, 1 skipped. flake8 clean.

@ivanzlenko ivanzlenko 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.

Re-reviewed 684a2c7. Both open code findings fixed; verified and resolved with the evidence on each thread.

Refusal precedence now behaves exactly as the new comments claim — checked all four orderings, not just the one the tests cover:

SORTED + unknown 7    -> ValueError: Vector storage is supported by VECTOR indexes only
VECTOR + unknown 7    -> ValueError: Unknown vector storage mode 7
VECTOR + 7, no bit 39 -> ValueError: Unknown vector storage mode 7   (not a capability gap)
VECTOR + INT8, no 42  -> NotSupportedByClusterError, unchanged
all four known modes  -> serialize, 82 bytes each

_VECTOR_QUANTIZATIONS renamed, #: correctly downgraded to a plain comment with it, no stale reference to the old public name, correctly absent from __all__.

Suite 158 passed, 18 skipped (was 152). flake8 clean on all four changed files.

No new findings. One cosmetic thing not worth a thread: the unknown-ordinal message is the only refusal that omits where, so with several indexes it does not say which one carries the bad ordinal — where is already built two lines above it.

The bit 41/42 thread stays open, and it is the only thing left. Re-checked today: gridgain#4009 is still state=OPEN, mergedAt=null, and master's ClientBitmaskFeature still ends at CONTINUOUS_QUERY_COMPACT_VALUE(40);. The client code is done — this only needs #4009 to land first, and to be re-checked for a renumber if it does not land clean.

ivanzlenko's re-review closed both code findings and left one cosmetic:
this was the only refusal that omitted the index name, so with several
indexes it did not say which one carried the bad ordinal. The where
clause is built two lines above; it is in the message now.

41 tests in the file pass, flake8 clean.

@ivanzlenko ivanzlenko 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.

Approving 8351a30.

The client work is complete and correct. Verified against the Java side rather than the description: bits 39/41/42, the 0..3 quantization ordinals, the 1024/64 ceilings and the wire order (quantization int, then maxSegments, queryThreads) all match ClientBitmaskFeature, VectorQuantization, QueryIndex and PlatformConfigurationUtils.writeQueryIndex on gridgain#4009. The gap at bit 40 is real and correctly left undeclared. The Java PR title's deleted-vector budget is a system property, not a per-index wire field, so nothing is missing from the layout.

All six code findings from the three earlier passes are fixed and re-verified individually. Latest commit lands the where addition, so every refusal now names the index it came from:

Unknown vector storage mode 7 (quantization=7 on index 'by_embedding'). Use a VectorQuantization value; ...

158 passed, 18 skipped, flake8 clean. Also hand-checked a mixed [SORTED, VECTOR, SORTED] array round-trips without stream desync, and that async parity holds byte-for-byte with the sync writer.

One merge condition, not a code objection — the open thread on bitmask_feature.py:29. gridgain#4009 is still state=OPEN, mergedAt=null and master's ClientBitmaskFeature still ends at CONTINUOUS_QUERY_COMPACT_VALUE(40);. Until it lands, this client advertises two bits no released server has, and #4009 has already renumbered once when master took 40. Land #4009 first, and re-check 41/42 here if it does not land clean — SERVER_VECTOR_BITS is a literal, so a second renumber would not be caught by this suite.

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