Skip to content

Tier 6 · Adoption gaps: M26, M27, M28, M29 and most of M30 - #47

Merged
tonytonycoder11 merged 5 commits into
mainfrom
feat/tier-6-adoption-gaps
Jul 31, 2026
Merged

Tier 6 · Adoption gaps: M26, M27, M28, M29 and most of M30#47
tonytonycoder11 merged 5 commits into
mainfrom
feat/tier-6-adoption-gaps

Conversation

@tonytonycoder11

Copy link
Copy Markdown
Contributor

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-ai and kdrant-langchain4j advertised a
Qdrant 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/should rather than nesting sub-filters, comparisons pick Qdrant's numeric or RFC 3339
range variant from the value's runtime type, and Spring AI's IS NULL maps to is_empty, which
unlike is_null also covers a missing key. A value Qdrant cannot express is rejected rather than
dropped, because a silently discarded condition widens a result set without saying so.

M27 (#20) · Deployment ergonomics. ensureCollection creates the collection when missing and
otherwise 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
getCollection did not previously return, so CollectionInfo now carries the collection's params and
payload schema. Ordering a scroll was 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 — pagination follows the order value instead and drops
the points a page repeats at the boundary. batchUpdate applies a mixed sequence of operations in one
ordered request, and the documentation says in those words that it is not transactional.

M28 (#21) · Observability. A kdrant-micrometer module timing each request as kdrant.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 header
to every request would change the bytes on the wire for callers who never asked. maxConnectionsPerRoute
and keepAliveTime as parameters of the REST factory, which is where the settings declined on
KdrantConfig land.

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 against
that 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 Benchmarks workflow that produces one: dispatch it
against 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. apiCheck reads the whole
change as additive, but two Kotlin details are not binary-compatible for every caller: new members on
QdrantClient and QdrantTransport break a class that implemented the old interfaces, and the fields
added to CollectionInfo, ScrollRequest and Record change their generated copy/componentN.
STABILITY.md now states both, framed as what a 1.x upgrade guarantees. If that framing is too
generous, this is a 2.0 rather than a 1.2.

Closes #18, closes #19, closes #20, closes #21.

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.
@tonytonycoder11
tonytonycoder11 requested a review from a team as a code owner July 31, 2026 08:17
@tonytonycoder11
tonytonycoder11 merged commit 18bc6e6 into main Jul 31, 2026
6 checks passed
@tonytonycoder11
tonytonycoder11 deleted the feat/tier-6-adoption-gaps branch July 31, 2026 08:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant