Tier 6 · Adoption gaps: M26, M27, M28, M29 and most of M30 - #47
Merged
Conversation
Both adapter modules advertised a Qdrant integration and then threw on any metadata filter, so the case they were written for did not work end to end. Each module now translates its framework's filter model into Kdrant's own: boolean chains flatten into a single must or should clause rather than nesting sub-filters, comparisons choose Qdrant's numeric or RFC 3339 range variant from the value's runtime type, and Spring AI's IS NULL becomes is_empty, which unlike Qdrant's is_null also covers a key that is absent altogether. A value Qdrant has no way to express is rejected rather than dropped. Silently discarding a condition would widen a result set without saying so, which is the one failure a filter must not have. The translators need to hand an already-built filter to the DSL, so search and prefetch gain a filter(Filter) overload and the client a delete by selector.
Three things a rerunnable bootstrap script and a resumable ETL job need, all carried forward from earlier tiers. ensureCollection creates the collection when it is missing and otherwise checks that the one already there is the one asked for. It compares only what the caller requested, the dense vector names with their size and distance and the sparse vector names, so a collection tuned after creation still passes; a mismatch fails loudly, because the alternative is an application that starts cleanly and only finds out about the wrong vector size on its first upsert. The check reads a config that getCollection did not previously return, so CollectionInfo now carries the collection's params and payload schema. Ordering a scroll is not just another request field. Qdrant returns no page cursor for an ordered scroll, so following next_page_offset would have stopped after the first page and quietly returned a fraction of the collection. The client pages on the order value instead, and because start_from is inclusive it remembers the ids tied at the boundary and drops the repeats, which keeps each point emitted exactly once. When more points share one order value than fit in a page there is no way forward, and that says so rather than truncating. batchUpdate applies a mixed sequence of point, vector and payload operations in one ordered request. It is not transactional and the documentation says so in those words, because a batch that half-applies is worth knowing about in advance. Direction was serializing as ASC rather than asc. Nothing shipped went through that path, since the query serializer writes the string by hand, but the ordered scroll would have been the first caller to hit it.
All three of these were possible through the configureClient seam and none of them shipped, which meant every application that wanted them wrote the same code. kdrant-micrometer times each request as kdrant.requests. Its operation tag is the route template rather than the URL: a deployment with ten thousand collections would otherwise produce ten thousand time series, and a metric that takes down the metrics backend is not observability. An X-Request-Id header ties a Kdrant call to the line Qdrant logs for it. It is off unless a provider is given, because adding a header to every request would change the bytes on the wire for callers who never asked for it, and the caller is also the only one who knows which trace id belongs there. maxConnectionsPerRoute and keepAliveTime are parameters of the REST factory rather than of KdrantConfig. They describe the engine's connection pool, not the connection, and KdrantConfig has to stay meaningful for an engine that has no pool at all. This is where the settings declined on KdrantConfig land.
A Qdrant release that renames a request field does not break loudly. The server ignores the spelling it no longer knows and the request quietly means something else, which is the failure this milestone exists to convert into a red build. The contract tests drive a real client call per operation through a recording engine and validate what actually went out against Qdrant's own OpenAPI document, treating an unknown property as a failure rather than as noise. Qdrant's document is vendored and pinned to the version the integration matrix runs against, so refreshing the file is the moment a wire change surfaces. All eighteen bodies the engine can send conform to 1.18.2 today. Kover was deferred because 0.9.1 did not work with the Kotlin 2.4 Gradle plugin. 0.9.9 does. The floor is 75% against 82.8% actual: it is there to catch a module that arrives with no tests at all, not to be inched towards. The release workflow now assembles the jars, attests them, and publishes those same files, so the provenance covers the artifacts that reach the registries rather than a rebuild that happens to look like them.
The migration guide maps io.qdrant:client onto Kdrant call by call, verified against that client's own source rather than written from memory. It leads with the three things that actually change, the port, ListenableFuture against suspend, and protobuf builders against the DSL, and it says where the official client is still the right answer, because a migration guide that cannot name a reason to stay is an advertisement. STABILITY.md now says what a 1.x upgrade really guarantees. Two Kotlin details are additive to apiCheck and still not binary-compatible for every caller: a new member on QdrantClient or QdrantTransport breaks a class that implemented the old interface, and a field added to a public data class changes its generated copy. Both are unavoidable for a client that grows with its server, so the honest move is to write down that a minor is a recompile rather than a jar swap. The published benchmark numbers are not here. The harness needs a running Qdrant and the machine this was written on has no container runtime, and a latency taken under those conditions is not a number worth putting in a README. What is here is the workflow that produces one: dispatch it against a chosen Qdrant image and it runs the harness on a clean runner and keeps the output.
This was referenced Jul 31, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tier 6 closes the gaps that made Kdrant harder to adopt than it needed to be. Five milestones, one
commit each, in board order.
M26 (#18) · Adapter filter translation.
kdrant-spring-aiandkdrant-langchain4jadvertised aQdrant integration and threw on any metadata filter, so the case they exist for did not work end to
end. Both now translate their framework's filter model into Kdrant's: boolean chains flatten into a
single
must/shouldrather than nesting sub-filters, comparisons pick Qdrant's numeric or RFC 3339rangevariant from the value's runtime type, and Spring AI'sIS NULLmaps tois_empty, whichunlike
is_nullalso covers a missing key. A value Qdrant cannot express is rejected rather thandropped, because a silently discarded condition widens a result set without saying so.
M27 (#20) · Deployment ergonomics.
ensureCollectioncreates the collection when missing andotherwise checks that the one already there has the vectors that were asked for, failing loudly on a
mismatch rather than leaving an application to find out on its first upsert. The check reads a config
getCollectiondid not previously return, soCollectionInfonow carries the collection's params andpayload schema. Ordering a
scrollwas not just another request field: Qdrant returns no page cursorfor an ordered scroll, so following
next_page_offsetwould have stopped after the first page andquietly returned a fraction of the collection — pagination follows the order value instead and drops
the points a page repeats at the boundary.
batchUpdateapplies a mixed sequence of operations in oneordered request, and the documentation says in those words that it is not transactional.
M28 (#21) · Observability. A
kdrant-micrometermodule timing each request askdrant.requests,tagged with the route template rather than the URL so a deployment with thousands of collections does
not become thousands of time series. An opt-in
X-Request-Id, off by default because adding a headerto every request would change the bytes on the wire for callers who never asked.
maxConnectionsPerRouteand
keepAliveTimeas parameters of the REST factory, which is where the settings declined onKdrantConfigland.M29 (#19) · Contract tests, coverage, provenance. Every request body the engine builds is captured
from a real client call and validated against Qdrant's own OpenAPI document, vendored and pinned to the
version the integration matrix runs against; an unknown property is a failure. All eighteen bodies
conform to 1.18.2 today. Kover works with Kotlin 2.4 now, so coverage is back, wired into CI with a 75%
floor against 82.8% actual. The release workflow assembles, attests and then publishes those same jars.
M30 (#22) · Switching cost. A migration guide from
io.qdrant:client, mapped call by call againstthat client's own source, and the design rationale in
STABILITY.md.Two things to decide before this is released
The benchmark numbers M30 asks for are not in this branch. The JMH harness needs a running Qdrant
and there was no container runtime available, and a latency measured under those conditions is not a
number worth publishing. What is here is the
Benchmarksworkflow that produces one: dispatch itagainst a chosen Qdrant image and it runs the harness on a clean runner. #22 should stay open until
a run happens and its numbers land.
This is a recompile, not a jar swap, and the version needs a decision.
apiCheckreads the wholechange as additive, but two Kotlin details are not binary-compatible for every caller: new members on
QdrantClientandQdrantTransportbreak a class that implemented the old interfaces, and the fieldsadded to
CollectionInfo,ScrollRequestandRecordchange their generatedcopy/componentN.STABILITY.mdnow states both, framed as what a1.xupgrade guarantees. If that framing is toogenerous, this is a
2.0rather than a1.2.Closes #18, closes #19, closes #20, closes #21.