Tier 10 and Tier 11 · Track Qdrant 1.19, and measure what was only asserted - #148
Merged
Conversation
Which Qdrant this client speaks to was written in fourteen places and compared in none, so it drifted where it was least visible. The vendored OpenAPI document said v1.18.2 in its README and was a master snapshot taken before 1.19.0 shipped, which left the contract test validating request bodies against fields no released server has. points.proto had been hand-edited to carry part of 1.19 while the proto README stated that nothing there is ever edited. The document cannot say where it came from. Qdrant ships "version": "master" under info at every released tag, v1.19.1 included, so info.version can never be the pin. The pin is qdrantVersion in gradle.properties instead, verifyQdrantPin fails the build when anything else in the repository names a newer Qdrant, and verifyVendoredQdrant supplies the evidence the document lacks by fetching the pinned tag and comparing byte for byte, over the protobuf definitions as well as the schema. refreshVendoredQdrant moves them together, which is now the only supported way to move. Taking upstream's bytes is what forces the rest of this change into the same commit. The refreshed protos reserve max_disk_usage_percent, which 1.19 replaced with the global quota API, and add oneof cases that make the filter mappings non-exhaustive, so the client has to answer for 1.19 in the same breath as it pins to it. So it answers for all of it. matchPrefix joins the filter DSL together with the keyword index option without which the filter is accepted and matches nothing, which is the failure M43 existed to remove and which this would otherwise have repeated. The two transports spell that option differently: REST takes a boolean, gRPC takes an empty message whose presence enables it. The model carries the boolean and each engine renders it, asserted on both sides, because a filter that works over one engine and not the other is worse than one that works over neither. relevanceFeedback is the eleventh query variant and the one an agent loop actually needs: a graded response to a query that already happened, rather than recommend's positive and negative targets, which is close and not the same. slice selects one of a number of deterministic partitions of the id space, so a scroll splits across workers without guessing how the ids are distributed and a sample reproduces. Qdrant hashes the id, so the split stays uniform for UUIDs from an upstream system. Every component that took an onDisk or alwaysRam flag now also takes a memory tier, a collection places its payload with payloadMemory, and TURBO4 stores only 4-bit quantized vectors with no originals. Where a caller sets both a tier and a flag the tier wins, which is Qdrant's rule and is stated on every memory property and in STABILITY.md, because a caller setting both and getting whichever the server prefers is the way this addition goes wrong. Two more that nobody planned, which is the point. The per-query IDF corpus computes sparse vector statistics over a filter rather than the whole collection, and min, max and acosh were missing from the formula language. Both shipped in 1.19.0 and neither had a board item, because noticing depended on somebody happening to read a release note. Qdrant watch reads it on a schedule instead and opens an issue when upstream is ahead of the pin. Every new shape is asserted against Qdrant's own v1.19.1 OpenAPI document in the contract test, which now names the operations it covers rather than counting them: a count is a check somebody eventually lowers to make a build pass.
|
This pull request lets the code touch the filesystem in 2 places.
What I could not follow95 calls resolve to a name whose type is written nowhere, so what they reach is not established here, and 43 more are in tests and benchmarks, which this report does not judge. Across everything it read that is 138 of 3367, 4%. That is too many to list, and where they are is more useful than which they are:
If one of those reaches the network or starts a process, this report does not show it. 50 files read · 38863 ms · against |
The gradle-minor-patch group, twenty-five updates: JUnit 6.1.3, Kotest 6.2.4, Spring Boot 4.1.1, Spring AI 2.0.1, langchain4j 1.19.0, Micrometer 1.17.1, Koog 1.2.0, gRPC 1.84.0, protobuf 4.36.1, OpenTelemetry 1.65.0, Guava 33.7.1-jre, the binary compatibility validator 0.18.2 and the GraalVM build tools 1.1.11. And actions/setup-java from 5 to 6 across every workflow, the new vendored-files job included. Clean build, no source change needed to take any of them.
…tting it Two Qdrant 1.19 additions that both change what a client can do about a cluster rather than what it can ask a collection. A routing token pins reads to a replica. `routeAffinity` on the search, scroll, count and retrieve paths sends `X-Qdrant-Route-Affinity`, so two reads carrying the same token are served by the same peer. The case is read-your-own-writes: a write replicates asynchronously and a read issued straight after it can land on a replica that has not caught up, and the only lever this client offered was `wait = true` on the write, which blocks the writer to fix a reader. It is per request rather than on the config because the thing that should be sticky is one reader's session, not the application. It travels as a header over REST and as metadata under the same key over gRPC, which is why it is a transient field on the request models rather than a body field: serializing it would send Qdrant a key it does not have. A batch is one call over either engine, so it carries one token, and both engines refuse a batch whose searches ask for different replicas rather than honouring the first and dropping the rest. A stale read a caller cannot explain is worse than an error they can. The quota is the other half. `quotas()` reads the cluster-wide limits and what each peer reports against them, `updateQuotas` replaces them. A client that learns about a limit only by being refused retries into the same wall: `RateLimited` says waiting is worth it and cannot say how much room is left. The update replaces rather than merges, and says so, because a config naming one limit silently drops the others. Qdrant serves it over HTTP only, so the gRPC engine refuses both by name, beside the eleven operations already in that list. That deprecates the two strict-mode ceilings `2.2.0` added, which is short enough between minors to look like churn, so STABILITY and the changelog say why rather than only what: the argument for modelling them was that a node refusing writes while still serving reads is the degraded state a client most needs to be predictable in, and that argument still holds. What moved is where the limit is set. The shared contract also grew the four cases Tier 10 owed it: prefix matching before and after the index that serves it, relevance feedback reranking, four sliced scrolls covering a collection once and repeatably, and 4-bit storage with a memory tier per component surviving a round trip. One correction in there. Prefix matching does not need its index the way phrase matching does. Qdrant checks a prefix condition per point without one and only refuses it under strict mode with unindexed filtering off, so the index is an accelerator, and the KDoc, changelog and tests that said otherwise now say what the server does.
…h the release Four items that are all about the distance between what this repository builds and what somebody can actually get. The Windows target compiled and shipped nowhere for two releases. It builds from any runner, and Qdrant publishes a Windows server, so the binary is now made, proven against a real node on a Windows runner, checksummed and attested like the other two, and attached. The one thing that stopped this being obvious is that the release job's steps were written for two platforms and hard-coded a `.kexe` extension and a `shasum` that Git Bash does not have. The tool itself grew the half that was cut rather than declined. `kdrant health` reports the three probes separately, because a node that is alive and not ready is the state somebody at a terminal is usually looking at, and exits on readiness so a script can use it. `kdrant collection create|describe|delete` covers the lifecycle `collections` only listed. `--shard N` scopes any snapshot action to one shard, which is how a sharded collection is really snapshotted and recovered, and `storage-snapshot` covers the whole node, which is what a full restore uses. 2.2.0 took three release attempts and every one of them failed in the CLI job, on defects any push could have caught: migrate could not create the collection it migrated into, and the step proving the binary called docker on a macOS runner. None of them could be caught earlier because nothing below the release workflow had ever started the binary. The proof is now a script both workflows run, and CI runs it on every push, so a release is the second time the tool has executed. Publishing releases itself now. `2.0.0` and `2.2.0` were both tagged, attested, green everywhere and unresolvable, because the deployment stops at staged until somebody opens the Portal. The argument for keeping that step was that it is the last look before an artifact becomes permanent; in practice it was a button pressed because the workflow was green, which is a check that had already run. What stands in for it is the step beside it, which asks Maven Central for every artifact the release claims and fails when one does not answer. And the README says what this is. A published ARM target, a 37 ms cold start in 42 MB and a 5.7 MB static binary read together as something that could hold an index on a device. It is a client. The device answer is Qdrant Edge, and the Platforms section now draws that line rather than leaving it to be worked out.
…ile again The multi-tenancy benchmark puts the same points and the same tenant key in two collections, indexes that key with isTenant on one and as an ordinary keyword on the other, and runs the same filtered search over both. A third row searches unfiltered, so a reader can see what a filter costs before the layout does. If the two rows come out level that is the number, and it says the layout matters at a size this harness cannot reach rather than that it does not matter. Writing it turned up that the harness has not compiled since the official client went to 1.19. Qdrant moved PointId out of Points and into Common when it split its protos, and the comparison benchmark still named the old one. Nothing reported it because the JMH source set is not part of `build`, so CI now compiles it. A benchmark harness that does not build is a slower way to have no benchmark than not writing one.
…o the library Relevance feedback does not do what this contract asserted it does, and CI said so before anything shipped. Qdrant drops the points the feedback names rather than reordering them among the rest, which is right for the loop it belongs to and is not what "rerank" suggests. The KDoc, the changelog and the contract now say it, and the contract asserts both halves: the judged points are gone, and the ones left come back in a different order from the same query without grades. Each TrustAnchors row is now a decision rather than a blank. Windows has no per-handle root override, so the machine store is the answer and there is nothing for a later release to add. Linux cannot pin because Ktor's Curl config exposes caInfo, caPath and sslVerify and nothing else, so libcurl's option is unreachable without an upstream change; that is the change, and until it lands, pin from the JVM. Darwin could honour a bundle through the challenge handler Ktor does expose, and deliberately does not: custom trust evaluation accepts more than it should when it is wrong, and the failure is silent by construction, so it is worth building only alongside a test proving it rejects a chain the bundle does not anchor. The RAG example had stayed on the 1.x client while the library grew past it, which matters because it is where somebody checks whether the README's argument survives contact with code. It now ingests through ingest with the resume token on disk, retrieves over a dense and a sparse ranking fused by reciprocal rank with the server applying IDF, creates its payload indexes with the parameters its filters need, and distinguishes a failure worth retrying from one that is not. Its README says which release each of those arrived in. It did not grow a second purpose.
This was referenced Sep 10, 2026
The first run of the comparison says Kdrant loses every operation against the official Java client, by 2.5x on a single search and 9x on a large upsert. Those numbers are worth publishing and they cannot be interpreted, because Kdrant was measured over REST and the official client over gRPC, so every gap is a gap against protobuf before it is a gap against a library. This client has a gRPC engine. Measuring it beside the other two turns one unanswerable question into two answerable ones: Kdrant against the official client asks which library, and Kdrant against itself asks how much of that was the wire format. Publishing the first without the second is how a number gets quoted out of context, which is the thing M68 says to avoid. Also records the two decisions M65 and M66 asked for rather than assumed. No Homebrew tap and no Scoop manifest: each is a standing obligation to keep a version number in a second place, and a curl from the release URL has no second copy in it. And `snapshot restore` stays out of the CLI proof script because it takes a location the server resolves, so a file:// URL names a path inside the runner rather than inside the container; the shared contract covers restoring against a real node.
The comparison harness shipped a release ago and had never been run, which mattered more than an unfinished task usually would: the whole argument for this client is ergonomics, and a reader who is unconvinced assumes they were bought with throughput. Nothing here contradicted them. It has been run, and the first thing it says is that Kdrant loses every row. Against the official Java client as each is normally configured, the default REST engine is 2.5x slower on a single search and 9.3x slower on a 500-point upsert. Those numbers are published first because they are true of the default choice. They are also uninterpretable on their own, which is why the harness grew a third column. Kdrant over its own gRPC engine answers a search in 0.65 ms against the official client's 0.56, and the upsert in 10.4 ms against 8.0. So the gap between the two defaults is HTTP and JSON against protobuf rather than a Kotlin client against a Java one, and the suspending functions and the typed DSL cost nothing detectable. Over the same protocol Kdrant is still behind, by 8% to 30%, and the worst row has a reason rather than an excuse: Kdrant splits an upsert at 256 points by default, so it sent two requests where the official client sent one. That default bounds the memory a large ingest holds and it costs a round trip here. It stays at the default, because the benchmark should measure what a caller gets rather than what a tuned caller could get. Multi-tenancy is measured too. A tenant-indexed collection is about 10% faster on the mean and 20% at the tail than the same data behind a plain keyword index, which is less than the architecture's reputation suggests and is the expected shape at this size: 20 000 points fit in one or two segments, so there is almost nothing to colocate. Published anyway, because a reader deciding between one collection per tenant and one collection with a tenant index is better served by the floor than by nothing.
The integration matrix's `:latest` cell failed on a message the shard matcher did not recognise. A node whose only replica for a shard is gone answers Service internal error: 1 of 1 read operations failed: Timeout error: Deadline Exceeded: code: 'Deadline expired before operation could complete', message: "Healthcheck timeout 2000ms exceeded" depending on which check gives up first, and the fan-out branch of the matcher knew "timed out" and not "timeout" or "deadline". So it fell through to ServerError, which is terminal, and told the caller not to retry a state that clears in seconds. That is the one classification mistake that changes what somebody does. Both engines keep their own copy of that matcher, which is how they came to disagree, and both are fixed here. The duplication itself is #154: the classifiers decide the retryable flag on exception types that live in kdrant-core, and that is where they belong, behind the same opt-in internal annotation KdrantJson already uses. The two phrasings a stopped peer actually produces are now in the unit test verbatim from the CI runs that produced them, beside an assertion on retryable rather than only on the exception type: a refactor that keeps the name and loses the meaning should fail.
This was referenced Sep 10, 2026
This was referenced Sep 10, 2026
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 10 and Tier 11, less two items, plus both Dependabot groups.
Closes #99, #100, #101, #102, #103, #105, #106, #107, #108, #109, #119, #120, #121, #123, #124, #153.
Advances #129. Supersedes #145 and #147.
Tier 10 · Qdrant 1.19 and the agent-era query surface
M60 started with a wrong exit criterion. It asked for the vendored schema's
info.versionto match areleased Qdrant tag, and Qdrant ships
"version": "master"at every released tag, v1.19.1 included. Thedocument cannot say where it came from, so the pin lives in
gradle.propertiesand three tasks standbehind it:
verifyQdrantPinincheckfails when anything names a newer Qdrant,verifyVendoredQdrantfetches the pinned tag in its own CI job and compares the eight protobuf files and the OpenAPI document
byte for byte, and
refreshVendoredQdrantmoves them together. All three found real drift on their firstrun, including a
points.protoedited by hand in a directory whose README says nothing there is edited.M56 to M59 are the 1.19 surface: prefix matching with the index that serves it, relevance feedback,
slice filtering, memory tiers and 4-bit primary storage. Plus two that had no board item, which is the
argument for the release watch below: the per-query IDF corpus, and
min/max/acoshin the formulalanguage. Every shape is asserted against Qdrant's own v1.19.1 OpenAPI document and against both engines'
mappings, and the shared contract now exercises all four against a real server.
Two facts found while doing it. Prefix matching does not need its index the way phrase matching does:
Qdrant checks the condition per point without one and only refuses it under strict mode, so the index is
an accelerator and the KDoc that said otherwise was wrong. And relevance feedback drops the points it
was given rather than reordering them among the rest, which CI caught before it shipped.
M70 draws the line the README left to a reader: this is a client, and a device that must answer
offline wants Qdrant Edge. M71 brings
example-ragup to the library it demonstrates, from a1.xupsertloop toingestwith a checkpoint, hybrid retrieval with server-side IDF, payload indexes withparameters, and a retryable-versus-terminal failure path.
Tier 11 · Operability at the size people run
M61 sends
X-Qdrant-Route-Affinityfrom the search, scroll, count and retrieve paths, soread-your-own-writes stops needing
wait = trueon the write. It is a header over REST and metadata overgRPC, which is why it rides the request models as a transient field; a batch is one call either way, so
both engines refuse a batch whose searches ask for different replicas rather than honouring the first.
M62 reads the cluster quota instead of discovering it, and deprecates the two strict-mode ceilings
2.2.0added. Two minors from introduction to deprecation looks like churn, so the changelog says whyrather than only what.
M63 closes every
TrustAnchorsrow. Windows has no per-handle root override and never will. Linuxcannot pin because Ktor's Curl config exposes
caInfo,caPathandsslVerifyand nothing else. Darwincould take a bundle through the challenge handler Ktor does expose, and deliberately does not: custom
trust evaluation accepts more than it should when it is wrong and the failure is silent, so it waits for
a test proving it rejects a chain the bundle does not anchor.
M65 ships the Windows binary that compiled and shipped nowhere for two releases, and the half of the
CLI that was cut:
kdrant health, the collection lifecycle,--shardon every snapshot action, andstorage-snapshot. M66 moves the proof script out of the release workflow so CI runs every subcommandagainst a real Qdrant on every push;
2.2.0took three release attempts and all three failed there.M67 makes publishing automatic and then checks that the version actually resolves from Maven Central,
because
2.0.0and2.2.0were both green, tagged and unresolvable.M64 and M68 publish numbers, including the ones Kdrant loses. The comparison harness had shipped
without ever being run, and it turned out not to compile: the official client moved
PointIdbetweengenerated classes at 1.19 and nothing noticed, because the JMH source set is not in
build. It is now.Run, it says Kdrant's REST engine is 2.5x slower than the official client on a single search and 9.3x on
a 500-point upsert. A third column makes that readable: over Kdrant's own gRPC engine the gap is 8% to
30%, so what the two defaults measure is HTTP and JSON against protobuf rather than one library against
another. The worst row is partly a round trip, because Kdrant splits an upsert at 256 points and sent two
requests where the official client sent one. Multi-tenancy: a tenant index is about 10% faster on the mean
and 20% at the tail, which is the floor rather than the headline, and published as such.
Dependabot
#147's whole gradle-minor-patch group, twenty-five updates, and #145's
actions/setup-javav6 across everyworkflow. No source change was needed for any of them. Close those two when this merges.
What this does not do
MCP SDK publishes native artifacts for every target the release attaches a CLI binary for, so a native
server is buildable and the item is worth opening. It is not started, because its exit criterion is that
an MCP client lists the tools and completes a search, and an unverified MCP server is exactly the thing
that looks done and is not.
text. Submitting to Kotlin Weekly and changing a public description are both outward-facing, and the
referrer reading a month out cannot happen now by construction.
2.3.0and2.4.0are not cut. 2.3.0 · Qdrant 1.19 and the agent-era query surface, the Tier 10 release #104 and 2.4.0 · Operability at the size people run, the Tier 11 release #110 are the release issues and a release is a decision.searchMatrixPairs,searchMatrixOffsetsandupdateCollectionare still absent from the contracttest, which The contract test's name promises more than it covers #152 tracks.
Verification
./gradlew buildgreen,koverVerifyincluded. 34 request bodies validate against Qdrant's own v1.19.1OpenAPI document. In CI: the shared contract passes over both engines against a real
v1.19.1and against:latest, the version matrix passes across v1.19.1, v1.18.3, v1.17.1 and v1.16.3, both native clientcontracts pass, the new vendored-files job passes, and the new CLI job runs every subcommand against a
real Qdrant.