From 75c72184ecb21b9fa9e420c1aaa0d32637f5a884 Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Fri, 31 Jul 2026 14:50:18 +0200 Subject: [PATCH 1/2] State what an engine has to do, once, for every engine The exit criterion for the gRPC engine is that it passes the same client test suite as the REST engine. That suite did not exist. What existed was a set of transport tests asserting HTTP bodies through Ktor's MockEngine, which is the right way to pin a wire format and the wrong way to compare two of them: a gRPC engine cannot satisfy an assertion about a JSON body, by construction. The one protocol-agnostic file, CollectionsIntegrationTest, built its client with the REST factory in its second line. So the suite is extracted into kdrant-testkit and never names a protocol. It takes a QdrantClient from its subclass, talks to a real Qdrant, and asserts on what comes back. Thirty tests where there were five: collection lifecycle and named vectors, points by list and by flow, search with filters and selectors, batch and grouped search, paged, filtered and ordered scroll, the four payload operations, named vector updates and deletes, payload indexes, ordered batch updates, aliases, facets, both distance matrix forms, health, cluster info, snapshots, and every matcher the filter DSL offers put to a server that has to understand it. Scope is what both engines can do. Qdrant's gRPC API has no telemetry, no metrics, no issues endpoint, no snapshot transfer and no shard-scope snapshots, so those stay in the REST module's own tests rather than being weakened here into something both can pass. Naming that boundary is the point: a shared suite that quietly drops what one side cannot do proves less than one that says so. The testkit is not published. Its only consumers are the two transport modules' test source sets, so it carries no publishing block and is out of public-API tracking. It lives in a main source set because a test source set cannot be depended on across modules, which is why detekt's function naming rule gets the same exclusion there that it already gives src/test. --- build.gradle.kts | 6 +- config/detekt/detekt.yml | 6 + kdrant-testkit/build.gradle.kts | 31 + .../kdrant/testkit/QdrantClientContract.kt | 667 ++++++++++++++++++ kdrant-transport-rest/build.gradle.kts | 4 +- .../rest/CollectionsIntegrationTest.kt | 140 ---- .../rest/RestClientContractIntegrationTest.kt | 19 + settings.gradle.kts | 1 + 8 files changed, 731 insertions(+), 143 deletions(-) create mode 100644 kdrant-testkit/build.gradle.kts create mode 100644 kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt delete mode 100644 kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/CollectionsIntegrationTest.kt create mode 100644 kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/RestClientContractIntegrationTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8b0b337..885e580 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,11 +15,12 @@ subprojects { version = "1.2.0" } -// The runnable example and the benchmark harness are not published libraries — exclude them from -// public-API tracking. +// The runnable example, the benchmark harness and the shared test suite are not published libraries — +// exclude them from public-API tracking. apiValidation { ignoredProjects.add("example-rag") ignoredProjects.add("benchmarks") + ignoredProjects.add("kdrant-testkit") // The gRPC engine's protobuf and stub classes are generated from Qdrant's own .proto files, so // their surface is Qdrant's to change, not ours to promise. Tracking them would bury the module's // real API — the transport factory — under thousands of generated lines. Everything hand-written @@ -39,6 +40,7 @@ configure( project(":kdrant-langchain4j"), project(":kdrant-micrometer"), project(":kdrant-koog"), + project(":kdrant-testkit"), project(":example-rag"), ), ) { diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 33694e5..080c938 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -6,6 +6,12 @@ naming: FunctionNaming: ignoreAnnotated: [] functionPattern: '[a-zA-Z][a-zA-Z0-9]*' + # kdrant-testkit holds the shared client contract, which is a test suite that happens to live in a + # main source set because two other modules' test source sets consume it. Its test names are + # backtick sentences like every other test in the repository, so it gets the exclusion detekt + # already gives src/test. + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/*.Test.kt', + '**/*.Spec.kt', '**/*.Spek.kt', '**/kdrant-testkit/**'] # DSL helpers are grouped by concept into *Dsl.kt files (e.g. PayloadDsl.kt holds PayloadBuilder + payloadOf). MatchingDeclarationName: active: false diff --git a/kdrant-testkit/build.gradle.kts b/kdrant-testkit/build.gradle.kts new file mode 100644 index 0000000..bfb56de --- /dev/null +++ b/kdrant-testkit/build.gradle.kts @@ -0,0 +1,31 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// Not published. This module exists so both engines can be held to the same behaviour, and its only +// consumers are the two transport modules' test source sets. It carries no `mavenPublishing` block and +// is excluded from public-API tracking for that reason. + +kotlin { + jvmToolchain(17) +} + +dependencies { + // `api` throughout: everything here is part of what a subclass of the contract sees. + api(project(":kdrant-core")) + api(platform(libs.junit.bom)) + api(libs.junit.jupiter) + api(platform(libs.testcontainers.bom)) + api(libs.testcontainers.qdrant) + api(libs.kotlinx.coroutines.core) +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = TestExceptionFormat.FULL + } +} diff --git a/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt b/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt new file mode 100644 index 0000000..5d4e552 --- /dev/null +++ b/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt @@ -0,0 +1,667 @@ +package dev.kdrant.testkit + +import dev.kdrant.KdrantException +import dev.kdrant.QdrantClient +import dev.kdrant.dsl.CreateCollectionBuilder +import dev.kdrant.dsl.payloadOf +import dev.kdrant.model.CollectionStatus +import dev.kdrant.model.DeleteSelector +import dev.kdrant.model.Direction +import dev.kdrant.model.Distance +import dev.kdrant.model.FacetValue +import dev.kdrant.model.OptimizersConfig +import dev.kdrant.model.PayloadSchemaType +import dev.kdrant.model.PointId +import dev.kdrant.model.PointStruct +import dev.kdrant.model.PointVectors +import dev.kdrant.model.VectorData +import dev.kdrant.model.VectorsConfig +import dev.kdrant.model.WithPayload +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.testcontainers.DockerClientFactory +import org.testcontainers.qdrant.QdrantContainer +import java.util.concurrent.atomic.AtomicInteger + +/** + * What a Kdrant engine has to do, stated once and run against every engine. + * + * The REST tests that came before this asserted HTTP bodies through Ktor's `MockEngine`, which is the + * right way to pin a wire format and the wrong way to compare two wire formats: a gRPC engine cannot + * satisfy an assertion about a JSON body by construction. So this suite never mentions a protocol. It + * receives a [QdrantClient] from the subclass, talks to a real Qdrant in Docker, and asserts on what + * comes back. Anything an engine has to do differently to pass is a difference the caller would have + * seen too. + * + * Scope is the operations both engines can perform. Qdrant's gRPC API has no telemetry, no metrics, no + * issues endpoint, no snapshot transfer and no shard-scope snapshots, so those stay in the REST module's + * own tests rather than being weakened here into something both can pass. The gRPC module asserts + * separately that each of them fails with a message naming REST. + * + * Skipped, not failed, when Docker is unavailable. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public abstract class QdrantClientContract { + + private lateinit var container: QdrantContainer + private lateinit var client: QdrantClient + private val collections = AtomicInteger() + + /** + * Builds the client under test. The container exposes both of Qdrant's ports, so an engine picks + * the one it speaks: [QdrantContainer.getGrpcPort], or `getMappedPort(6333)` for REST. + */ + protected abstract fun connect(container: QdrantContainer): QdrantClient + + @BeforeAll + public fun startQdrant() { + assumeTrue( + DockerClientFactory.instance().isDockerAvailable, + "Docker not available; skipping the Qdrant client contract", + ) + container = QdrantContainer(IMAGE).also { it.start() } + client = connect(container) + } + + @AfterAll + public fun stopQdrant() { + if (::client.isInitialized) client.close() + if (::container.isInitialized && container.isRunning) container.close() + } + + // --- Collections ------------------------------------------------------------------------- + + @Test + public fun `a collection can be created, found, described and deleted`(): Unit = runBlocking { + val name = nextName() + assertFalse(client.collectionExists(name), "the collection existed before it was created") + + client.createCollection(name) { + vector { size = 4; distance = Distance.COSINE } + onDiskPayload = true + } + + assertTrue(client.collectionExists(name)) + assertEquals(CollectionStatus.GREEN, client.getCollection(name).status) + assertTrue(client.listCollections().any { it.name == name }, "the collection is missing from the list") + + client.deleteCollection(name) + assertFalse(client.collectionExists(name)) + } + + @Test + public fun `a named-vectors collection reports the names, sizes and distances it was created with`(): + Unit = runBlocking { + withCollection( + create = { + namedVector("text") { size = 8; distance = Distance.COSINE } + namedVector("image") { size = 16; distance = Distance.DOT } + }, + ) { name -> + val vectors = (client.getCollection(name).config?.params?.vectors as VectorsConfig.Named).vectors + + assertEquals(setOf("text", "image"), vectors.keys) + assertEquals(8L, vectors.getValue("text").size) + assertEquals(Distance.COSINE, vectors.getValue("text").distance) + assertEquals(16L, vectors.getValue("image").size) + assertEquals(Distance.DOT, vectors.getValue("image").distance) + } + } + + @Test + public fun `ensureCollection creates once and then accepts the collection it finds`(): Unit = runBlocking { + val name = nextName() + try { + assertTrue(client.ensureCollection(name) { vector { size = 4; distance = Distance.COSINE } }) + assertFalse(client.ensureCollection(name) { vector { size = 4; distance = Distance.COSINE } }) + } finally { + drop(name) + } + } + + @Test + public fun `updateCollection is accepted and leaves the collection serving`(): Unit = runBlocking { + withCollection { name -> + client.updateCollection(name) { optimizers = OptimizersConfig(indexingThreshold = 30_000) } + + assertEquals(CollectionStatus.GREEN, client.getCollection(name).status) + } + } + + // --- Points ------------------------------------------------------------------------------ + + @Test + public fun `points upserted from a list come back by id with their payload and vector`(): Unit = runBlocking { + withCollection { name -> + client.upsert(name, wait = true) { + point(1) { + vector(0.1f, 0.2f, 0.3f, 0.4f) + payload("lang" to "it", "year" to 2024, "public" to true) + } + point(UUID) { vector(0.4f, 0.3f, 0.2f, 0.1f) } + } + + val records = client.retrieve(name, listOf(PointId.num(1)), WithPayload.All, withVector = true) + + val record = records.single() + assertEquals(PointId.num(1), record.id) + assertEquals("it", record.payload?.get("lang")?.toString()?.trim('"')) + assertEquals(listOf(0.1f, 0.2f, 0.3f, 0.4f), denseOf(record.vector)) + assertEquals(2L, client.count(name)) + } + } + + @Test + public fun `a uuid id and a numeric id are two different points`(): Unit = runBlocking { + withCollection { name -> + client.upsert(name, wait = true) { + point(1) { vector(0.1f, 0.2f, 0.3f, 0.4f) } + point(UUID) { vector(0.1f, 0.2f, 0.3f, 0.4f) } + } + + val ids = client.retrieve(name, listOf(PointId.num(1), PointId.uuid(UUID))).map { it.id }.toSet() + + assertEquals(setOf(PointId.num(1), PointId.uuid(UUID)), ids) + } + } + + @Test + public fun `points upserted from a flow are all stored`(): Unit = runBlocking { + withCollection { name -> + val points = (1L..25L).map { id -> + PointStruct( + id = PointId.num(id), + vector = VectorData.Dense(listOf(0.1f, 0.2f, 0.3f, id / 100f)), + payload = payloadOf("n" to id), + ) + } + + client.upsert(name, points.asFlow(), wait = true) + + assertEquals(25L, client.count(name)) + } + } + + @Test + public fun `count honours a filter, and an unmatched filter counts zero`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + assertEquals(3L, client.count(name)) + assertEquals(2L, client.count(name) { must { "lang" eq "it" } }) + assertEquals(0L, client.count(name) { must { "lang" eq "de" } }) + } + } + + @Test + public fun `points can be deleted by id and by filter`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + client.delete(name, listOf(PointId.num(1)), wait = true) + assertEquals(2L, client.count(name)) + + client.delete(name, wait = true) { must { "lang" eq "it" } } + assertEquals(1L, client.count(name)) + } + } + + // --- Search ------------------------------------------------------------------------------ + + @Test + public fun `search returns the nearest point first, with the payload that was asked for`(): Unit = + runBlocking { + withCollection { name -> + seed(name) + + val hits = client.search(name) { + query(0.9f, 0.1f, 0.0f, 0.0f) + limit = 3 + withPayload = WithPayload.All + } + + assertEquals(3, hits.size) + assertEquals(PointId.num(1), hits.first().id, "the point aligned with the query should rank first") + assertNotNull(hits.first().payload) + assertTrue(hits[0].score >= hits[1].score, "hits should come back ordered by score") + } + } + + @Test + public fun `a search filter narrows the candidates rather than the returned page`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val hits = client.search(name) { + query(0.9f, 0.1f, 0.0f, 0.0f) + limit = 10 + withPayload = WithPayload.All + filter { must { "lang" eq "en" } } + } + + assertEquals(1, hits.size) + assertEquals(PointId.num(3), hits.single().id) + } + } + + @Test + public fun `withPayload selects fields, and withVector decides whether vectors come back`(): Unit = + runBlocking { + withCollection { name -> + seed(name) + + val included = client.search(name) { + query(0.9f, 0.1f, 0.0f, 0.0f) + limit = 1 + withPayload = WithPayload.include("lang") + withVector = true + }.single() + val bare = client.search(name) { + query(0.9f, 0.1f, 0.0f, 0.0f) + limit = 1 + }.single() + + assertEquals(setOf("lang"), included.payload?.keys) + assertNotNull(included.vector) + assertNull(bare.payload) + assertNull(bare.vector) + } + } + + @Test + public fun `searchBatch answers each query in the order they were given`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val results = client.searchBatch(name) { + search { query(0.9f, 0.1f, 0.0f, 0.0f); limit = 1 } + search { query(0.0f, 0.0f, 0.1f, 0.9f); limit = 1 } + } + + assertEquals(2, results.size) + assertEquals(PointId.num(1), results[0].single().id) + assertEquals(PointId.num(3), results[1].single().id) + } + } + + @Test + public fun `searchGroups groups the hits by a payload field`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + client.createPayloadIndex(name, "lang", PayloadSchemaType.KEYWORD, wait = true) + val groups = client.searchGroups(name, groupBy = "lang", groupSize = 2, limit = 5) { + query(0.9f, 0.1f, 0.0f, 0.0f) + } + + assertEquals(2, groups.size, "two languages were seeded, so there should be two groups") + assertTrue(groups.all { it.hits.isNotEmpty() }) + } + } + + // --- Scroll ------------------------------------------------------------------------------ + + @Test + public fun `scroll emits every point exactly once across pages`(): Unit = runBlocking { + withCollection { name -> + client.upsert(name, wait = true) { + for (id in 1L..30L) point(id) { vector(0.1f, 0.2f, 0.3f, id / 100f) } + } + + val ids = client.scroll(name, pageSize = 7).map { it.id }.toList() + + assertEquals(30, ids.size) + assertEquals(30, ids.toSet().size, "a point was emitted twice") + } + } + + @Test + public fun `a filtered scroll returns only the matching points`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val ids = client.scroll(name, pageSize = 2) { filter { must { "lang" eq "it" } } }.map { it.id }.toList() + + assertEquals(listOf(PointId.num(1), PointId.num(2)), ids.sortedBy { (it as PointId.Num).value }) + } + } + + @Test + public fun `an ordered scroll comes back in the order it asked for`(): Unit = runBlocking { + withCollection { name -> + client.createPayloadIndex(name, "n", PayloadSchemaType.INTEGER, wait = true) + client.upsert(name, wait = true) { + for (id in 1L..10L) point(id) { vector(0.1f, 0.2f, 0.3f, 0.4f); payload("n" to id) } + } + + val order = client.scroll(name, pageSize = 3) { orderBy("n", Direction.DESC) } + .map { it.payload?.get("n").toString().toLong() } + .toList() + + assertEquals((10L downTo 1L).toList(), order) + } + } + + // --- Payload and vectors ----------------------------------------------------------------- + + @Test + public fun `setPayload merges, overwritePayload replaces, the other two remove`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val one = DeleteSelector.Ids(listOf(PointId.num(1))) + + client.setPayload(name, payloadOf("reviewed" to true), one, wait = true) + assertEquals(setOf("lang", "year", "reviewed"), payloadKeys(name, 1)) + + client.overwritePayload(name, payloadOf("lang" to "it"), one, wait = true) + assertEquals(setOf("lang"), payloadKeys(name, 1)) + + client.setPayload(name, payloadOf("reviewed" to true), one, wait = true) + client.deletePayload(name, listOf("reviewed"), one, wait = true) + assertEquals(setOf("lang"), payloadKeys(name, 1)) + + client.clearPayload(name, one, wait = true) + assertTrue(payloadKeys(name, 1).isEmpty()) + } + } + + @Test + public fun `named vectors can be updated and deleted one at a time`(): Unit = runBlocking { + withCollection( + create = { + namedVector("text") { size = 2; distance = Distance.COSINE } + namedVector("image") { size = 2; distance = Distance.COSINE } + }, + ) { name -> + client.upsert(name, wait = true) { + point(1) { vector("text" to listOf(0.1f, 0.2f), "image" to listOf(0.3f, 0.4f)) } + } + + client.updateVectors( + name, + listOf( + PointVectors( + PointId.num(1), + VectorData.Named( + mapOf( + "text" to VectorData.Dense(listOf(0.9f, 0.1f)), + ), + ), + ), + ), + wait = true, + ) + val updated = client.retrieve(name, listOf(PointId.num(1)), withVector = true).single() + assertEquals( + listOf(0.9f, 0.1f), + ((updated.vector as VectorData.Named).vectors.getValue("text") as VectorData.Dense).values, + ) + + client.deleteVectors(name, listOf("image"), DeleteSelector.Ids(listOf(PointId.num(1))), wait = true) + val trimmed = client.retrieve(name, listOf(PointId.num(1)), withVector = true).single() + assertEquals(setOf("text"), (trimmed.vector as VectorData.Named).vectors.keys) + } + } + + @Test + public fun `a payload index can be created and dropped`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + client.createPayloadIndex(name, "lang", PayloadSchemaType.KEYWORD, wait = true) + assertTrue(client.getCollection(name).payloadSchema.containsKey("lang")) + + client.deletePayloadIndex(name, "lang", wait = true) + assertFalse(client.getCollection(name).payloadSchema.containsKey("lang")) + } + } + + @Test + public fun `batchUpdate applies its operations in order`(): Unit = runBlocking { + withCollection { name -> + client.batchUpdate(name, wait = true) { + upsert { + point(1) { vector(0.1f, 0.2f, 0.3f, 0.4f); payload("stale" to true) } + point(2) { vector(0.2f, 0.1f, 0.4f, 0.3f); payload("stale" to false) } + } + setPayload(payloadOf("reviewed" to true), byId(2L)) + delete(byFilter { must { "stale" eq true } }) + } + + // The delete sees the upsert that preceded it, and the point it removed is the one the + // filter matched at that moment rather than before the batch started. + assertEquals(1L, client.count(name)) + assertEquals(setOf("stale", "reviewed"), payloadKeys(name, 2)) + } + } + + // --- Filters against a real server ------------------------------------------------------- + + @Test + public fun `the filter clauses combine the way Qdrant combines them`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + assertEquals(2L, client.count(name) { must { "lang" eq "it"; "year" gte 2023 } }) + assertEquals(3L, client.count(name) { should { "lang" eq "it"; "lang" eq "en" } }) + assertEquals(1L, client.count(name) { mustNot { "lang" eq "it" } }) + assertEquals(3L, client.count(name) { minShould(1) { "lang" eq "it"; "year" gte 2020 } }) + } + } + + @Test + public fun `every matcher the DSL offers reaches the server and is understood`(): Unit = runBlocking { + withCollection { name -> + client.upsert(name, wait = true) { + point(1) { + vector(0.1f, 0.2f, 0.3f, 0.4f) + payload( + "lang" to "it", + "year" to 2024, + "score" to 4.5, + "title" to "a vector database for Kotlin", + "tags" to listOf("kotlin", "qdrant"), + "at" to "2024-06-01T00:00:00Z", + ) + } + } + // The text matchers need a full-text index; the others read the payload directly. + client.createPayloadIndex(name, "title", PayloadSchemaType.TEXT, wait = true) + client.createPayloadIndex(name, "at", PayloadSchemaType.DATETIME, wait = true) + + assertEquals(1L, client.count(name) { must { "lang" eq "it" } }) + assertEquals(1L, client.count(name) { must { matchAny("lang", "it", "en") } }) + assertEquals(1L, client.count(name) { must { matchExcept("lang", "de") } }) + assertEquals(1L, client.count(name) { must { matchText("title", "vector Kotlin") } }) + assertEquals(1L, client.count(name) { must { matchTextAny("title", "vector rust") } }) + assertEquals(1L, client.count(name) { must { matchPhrase("title", "vector database") } }) + assertEquals(1L, client.count(name) { must { "score" between 4.0..5.0 } }) + assertEquals(1L, client.count(name) { must { valuesCount("tags", gte = 2) } }) + assertEquals(1L, client.count(name) { must { hasId(PointId.num(1)) } }) + assertEquals(1L, client.count(name) { must { hasVector("") } }) + assertEquals(1L, client.count(name) { must { isEmpty("missing") } }) + assertEquals( + 1L, + client.count(name) { + must { datetimeRange("at", gte = "2024-01-01T00:00:00Z", lt = "2025-01-01T00:00:00Z") } + }, + ) + assertEquals(1L, client.count(name) { must { filter { must { "lang" eq "it" } } } }) + } + } + + // --- Aliases ----------------------------------------------------------------------------- + + @Test + public fun `an alias can be created, listed, renamed and dropped`(): Unit = runBlocking { + withCollection { name -> + val alias = "$name-alias" + val renamed = "$name-current" + + client.updateAliases { createAlias(collection = name, alias = alias) } + assertTrue(client.listAliases().any { it.aliasName == alias }) + assertEquals(listOf(alias), client.listCollectionAliases(name).map { it.aliasName }) + + client.updateAliases { renameAlias(from = alias, to = renamed) } + assertEquals(listOf(renamed), client.listCollectionAliases(name).map { it.aliasName }) + + client.updateAliases { deleteAlias(renamed) } + assertTrue(client.listCollectionAliases(name).isEmpty()) + } + } + + // --- Analytics --------------------------------------------------------------------------- + + @Test + public fun `facet counts the distinct values of a payload field`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + client.createPayloadIndex(name, "lang", PayloadSchemaType.KEYWORD, wait = true) + val hits = client.facet(name, key = "lang", exact = true).associate { it.value to it.count } + + assertEquals(2L, hits[FacetValue.StringValue("it")]) + assertEquals(1L, hits[FacetValue.StringValue("en")]) + } + } + + @Test + public fun `the distance matrix comes back in both of its forms`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val pairs = client.searchMatrixPairs(name) { sample = 10; limit = 2 } + val offsets = client.searchMatrixOffsets(name) { sample = 10; limit = 2 } + + assertTrue(pairs.pairs.isNotEmpty(), "the pairs form returned no edges") + assertEquals(offsets.offsetsRow.size, offsets.offsetsCol.size) + assertEquals(offsets.offsetsRow.size, offsets.scores.size) + assertTrue(offsets.ids.isNotEmpty()) + } + } + + // --- Service ----------------------------------------------------------------------------- + + @Test + public fun `a running node reports itself healthy, ready and alive`(): Unit = runBlocking { + assertTrue(client.healthz(), "healthz") + assertTrue(client.readyz(), "readyz") + assertTrue(client.livez(), "livez") + } + + @Test + public fun `cluster info describes the collection's shards`(): Unit = runBlocking { + withCollection { name -> + val info = client.collectionClusterInfo(name) + + assertTrue(info.localShards.isNotEmpty() || info.remoteShards.isNotEmpty(), "no shard reported") + } + } + + // --- Snapshots --------------------------------------------------------------------------- + + @Test + public fun `a collection snapshot can be created, listed and deleted`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val snapshot = client.createSnapshot(name) + assertTrue(client.listSnapshots(name).any { it.name == snapshot.name }) + + client.deleteSnapshot(name, snapshot.name) + assertTrue(client.listSnapshots(name).none { it.name == snapshot.name }) + } + } + + @Test + public fun `a whole-storage snapshot can be created, listed and deleted`(): Unit = runBlocking { + withCollection { name -> + seed(name) + + val snapshot = client.createStorageSnapshot() + assertTrue(client.listStorageSnapshots().any { it.name == snapshot.name }) + + client.deleteStorageSnapshot(snapshot.name) + assertTrue(client.listStorageSnapshots().none { it.name == snapshot.name }) + } + } + + // --- Failures ---------------------------------------------------------------------------- + + @Test + public fun `an operation on a collection that does not exist reports it as such`(): Unit = runBlocking { + val missing = "no-such-collection-${collections.incrementAndGet()}" + + assertThrowsCollectionNotFound { client.getCollection(missing) } + assertThrowsCollectionNotFound { client.count(missing) } + assertThrowsCollectionNotFound { client.search(missing) { query(0.1f, 0.2f, 0.3f, 0.4f) } } + } + + // --- Fixtures ---------------------------------------------------------------------------- + + private suspend fun assertThrowsCollectionNotFound(block: suspend () -> Unit) { + val error = runCatching { block() }.exceptionOrNull() + assertTrue( + error is KdrantException.CollectionNotFound, + "expected CollectionNotFound, got ${error?.let { it::class.simpleName + ": " + it.message }}", + ) + } + + /** Three points, two Italian and one English, spread far enough apart that ranking is unambiguous. */ + private suspend fun seed(name: String) { + client.upsert(name, wait = true) { + point(1) { vector(1.0f, 0.0f, 0.0f, 0.0f); payload("lang" to "it", "year" to 2024) } + point(2) { vector(0.0f, 1.0f, 0.0f, 0.0f); payload("lang" to "it", "year" to 2023) } + point(3) { vector(0.0f, 0.0f, 0.0f, 1.0f); payload("lang" to "en", "year" to 2022) } + } + } + + private suspend fun payloadKeys(name: String, id: Long): Set = + client.retrieve(name, listOf(PointId.num(id)), WithPayload.All).single().payload?.keys.orEmpty() + + private fun denseOf(vector: VectorData?): List? = (vector as? VectorData.Dense)?.values + + private fun nextName(): String = "contract-${collections.incrementAndGet()}" + + private suspend fun drop(name: String) { + runCatching { client.deleteCollection(name) } + } + + /** + * Runs [block] against a collection of its own. Every test gets a fresh name, so a failure leaves + * nothing behind for the next one to trip over and the tests do not have to run in order. + */ + private suspend fun withCollection( + create: CreateCollectionBuilder.() -> Unit = { + vector { size = 4; distance = Distance.COSINE } + }, + block: suspend (String) -> Unit, + ) { + val name = nextName() + client.createCollection(name, create) + try { + block(name) + } finally { + drop(name) + } + } + + private companion object { + /** Overridable so CI can hold both engines to a matrix of Qdrant versions. */ + val IMAGE: String = System.getenv("QDRANT_IMAGE") ?: "qdrant/qdrant:v1.18.2" + const val UUID = "550e8400-e29b-41d4-a716-446655440000" + } +} diff --git a/kdrant-transport-rest/build.gradle.kts b/kdrant-transport-rest/build.gradle.kts index f5cb79c..10d618e 100644 --- a/kdrant-transport-rest/build.gradle.kts +++ b/kdrant-transport-rest/build.gradle.kts @@ -28,7 +28,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.ktor.client.mock) - // Integration tests spin up a real Qdrant in Docker. + // Integration tests spin up a real Qdrant in Docker. The testkit carries the shared client + // contract both engines are held to, and brings JUnit and Testcontainers with it. + testImplementation(project(":kdrant-testkit")) testImplementation(platform(libs.testcontainers.bom)) testImplementation(libs.testcontainers.qdrant) } diff --git a/kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/CollectionsIntegrationTest.kt b/kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/CollectionsIntegrationTest.kt deleted file mode 100644 index f67fa46..0000000 --- a/kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/CollectionsIntegrationTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -package dev.kdrant.transport.rest - -import dev.kdrant.model.CollectionStatus -import dev.kdrant.model.Distance -import dev.kdrant.model.PointId -import dev.kdrant.model.WithPayload -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Assumptions.assumeTrue -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.testcontainers.DockerClientFactory -import org.testcontainers.qdrant.QdrantContainer - -/** - * End-to-end tests for collection operations against a real Qdrant in Docker. - * Skipped (not failed) when Docker is unavailable. Runs in CI where Docker is present. - */ -class CollectionsIntegrationTest { - - companion object { - // Image is overridable so CI can run this suite against a matrix of Qdrant versions. - private val image = System.getenv("QDRANT_IMAGE") ?: "qdrant/qdrant:v1.18.2" - private val container = QdrantContainer(image) - - @BeforeAll - @JvmStatic - fun startContainer() { - assumeTrue( - DockerClientFactory.instance().isDockerAvailable, - "Docker not available; skipping Qdrant integration test", - ) - container.start() - } - - @AfterAll - @JvmStatic - fun stopContainer() { - if (container.isRunning) container.close() - } - } - - private fun newClient() = Kdrant(host = container.host, port = container.getMappedPort(6333)) - - @Test - fun `create then delete a single-vector collection round-trips`() = runBlocking { - newClient().use { qdrant -> - qdrant.createCollection("docs") { - vector { size = 4; distance = Distance.COSINE } - onDiskPayload = true - } - qdrant.deleteCollection("docs") - } - } - - @Test - fun `create then delete a named-vectors collection round-trips`() = runBlocking { - newClient().use { qdrant -> - qdrant.createCollection("multi") { - namedVector("text") { size = 8; distance = Distance.COSINE } - namedVector("image") { size = 16; distance = Distance.DOT } - } - qdrant.deleteCollection("multi") - } - } - - @Test - fun `create collection then upsert points`() = runBlocking { - newClient().use { qdrant -> - qdrant.createCollection("vectors") { - vector { size = 3; distance = Distance.COSINE } - } - qdrant.upsert("vectors", wait = true) { - point(1) { - vector(0.1f, 0.2f, 0.3f) - payload("lang" to "it", "year" to 2024) - } - point("550e8400-e29b-41d4-a716-446655440000") { - vector(0.4f, 0.5f, 0.6f) - } - } - qdrant.deleteCollection("vectors") - } - } - - @Test - fun `search, scroll and delete round-trip against a real server`() = runBlocking { - newClient().use { qdrant -> - qdrant.createCollection("rag") { vector { size = 4; distance = Distance.COSINE } } - qdrant.upsert("rag", wait = true) { - point(1) { vector(0.1f, 0.2f, 0.3f, 0.4f); payload("lang" to "en") } - point(2) { vector(0.2f, 0.1f, 0.4f, 0.3f); payload("lang" to "it") } - } - - val hits = qdrant.search("rag") { - query(0.1f, 0.2f, 0.3f, 0.4f) - limit = 5 - withPayload = WithPayload.All - } - assertTrue(hits.isNotEmpty(), "search returned no hits") - - val all = qdrant.scroll("rag", pageSize = 1).map { it.id }.toList() - assertEquals(2, all.size, "scroll should return both points") - - qdrant.delete("rag", wait = true) { must { "lang" eq "it" } } - val remaining = qdrant.scroll("rag").toList() - assertEquals(1, remaining.size, "one point should remain after delete-by-filter") - - qdrant.deleteCollection("rag") - } - } - - @Test - fun `exists, info, count and retrieve against a real server`() = runBlocking { - newClient().use { qdrant -> - assertFalse(qdrant.collectionExists("m9")) - qdrant.createCollection("m9") { vector { size = 3; distance = Distance.COSINE } } - assertTrue(qdrant.collectionExists("m9")) - assertEquals(CollectionStatus.GREEN, qdrant.getCollection("m9").status) - - qdrant.upsert("m9", wait = true) { - point(1) { vector(0.1f, 0.2f, 0.3f) } - point(2) { vector(0.4f, 0.5f, 0.6f) } - } - assertEquals(2L, qdrant.count("m9")) - assertEquals(0L, qdrant.count("m9") { must { "missing" eq "x" } }) - - val records = qdrant.retrieve("m9", ids = listOf(PointId.num(1))) - assertEquals(1, records.size) - assertEquals(PointId.num(1), records[0].id) - - qdrant.deleteCollection("m9") - } - } -} diff --git a/kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/RestClientContractIntegrationTest.kt b/kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/RestClientContractIntegrationTest.kt new file mode 100644 index 0000000..d9ded29 --- /dev/null +++ b/kdrant-transport-rest/src/test/kotlin/dev/kdrant/transport/rest/RestClientContractIntegrationTest.kt @@ -0,0 +1,19 @@ +package dev.kdrant.transport.rest + +import dev.kdrant.QdrantClient +import dev.kdrant.testkit.QdrantClientContract +import org.testcontainers.qdrant.QdrantContainer + +/** + * The shared client contract, run over the REST engine. Everything asserted here is asserted about + * behaviour rather than about HTTP, which is what lets a second engine be held to the same file. + * + * The REST-only operations — telemetry, metrics, issues, snapshot transfer and shard-scope snapshots — + * are not in the contract, because Qdrant's gRPC API has no equivalent. They are covered by this + * module's own transport tests. + */ +class RestClientContractIntegrationTest : QdrantClientContract() { + + override fun connect(container: QdrantContainer): QdrantClient = + Kdrant(host = container.host, port = container.getMappedPort(6333)) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3139b31..14863d8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,7 @@ include( "kdrant-micrometer", "kdrant-koog", "kdrant-transport-grpc", + "kdrant-testkit", "example-rag", "benchmarks", ) From b621649f6bc4b4864d356cb9c0eb37fc846c65b9 Mon Sep 17 00:00:00 2001 From: TonyTonyCoder11 Date: Fri, 31 Jul 2026 15:19:23 +0200 Subject: [PATCH 2/2] Assert the client, not Qdrant's normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests in the new contract failed on a real server for two reasons, both of them the tests being wrong rather than the engine. A cosine collection stores the unit vector: Qdrant normalizes on write, because cosine distance only depends on direction. Two round-trip assertions were reading that back and comparing it to what went in. They use dot distance now, which stores the vector as written, and the normalization gets a test of its own — it is the server's behaviour, it surprises people, and it is worth pinning where someone will find it. Phrase matching is a gap rather than a mismatch. Qdrant matches a phrase only against a text index created with phrase_matching: true, and createPayloadIndex cannot ask for that, so the filter goes out, is accepted, and matches nothing. The assertion is removed and the reason written where it was: asserting zero would pin the gap as if it were the behaviour. --- .../kdrant/testkit/QdrantClientContract.kt | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt b/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt index 5d4e552..d9e06b5 100644 --- a/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt +++ b/kdrant-testkit/src/main/kotlin/dev/kdrant/testkit/QdrantClientContract.kt @@ -144,7 +144,10 @@ public abstract class QdrantClientContract { @Test public fun `points upserted from a list come back by id with their payload and vector`(): Unit = runBlocking { - withCollection { name -> + // Dot rather than cosine: a cosine collection stores the unit vector, which is Qdrant's + // behaviour and not the client's, and would make this assert normalization instead of a round + // trip. `a cosine collection stores the vector normalized` pins that separately. + withCollection(create = { vector { size = 4; distance = Distance.DOT } }) { name -> client.upsert(name, wait = true) { point(1) { vector(0.1f, 0.2f, 0.3f, 0.4f) @@ -163,6 +166,20 @@ public abstract class QdrantClientContract { } } + @Test + public fun `a cosine collection stores the vector normalized, not as it was written`(): Unit = runBlocking { + // Qdrant normalizes on write for cosine, because the distance only depends on direction. It is + // the server's behaviour rather than the client's, and it is the reason a vector read back from + // a cosine collection does not equal the one that went in. + withCollection { name -> + client.upsert(name, wait = true) { point(1) { vector(0.0f, 0.0f, 0.0f, 2.0f) } } + + val stored = client.retrieve(name, listOf(PointId.num(1)), withVector = true).single().vector + + assertEquals(listOf(0.0f, 0.0f, 0.0f, 1.0f), denseOf(stored)) + } + } + @Test public fun `a uuid id and a numeric id are two different points`(): Unit = runBlocking { withCollection { name -> @@ -382,8 +399,8 @@ public abstract class QdrantClientContract { public fun `named vectors can be updated and deleted one at a time`(): Unit = runBlocking { withCollection( create = { - namedVector("text") { size = 2; distance = Distance.COSINE } - namedVector("image") { size = 2; distance = Distance.COSINE } + namedVector("text") { size = 2; distance = Distance.DOT } + namedVector("image") { size = 2; distance = Distance.DOT } }, ) { name -> client.upsert(name, wait = true) { @@ -487,7 +504,10 @@ public abstract class QdrantClientContract { assertEquals(1L, client.count(name) { must { matchExcept("lang", "de") } }) assertEquals(1L, client.count(name) { must { matchText("title", "vector Kotlin") } }) assertEquals(1L, client.count(name) { must { matchTextAny("title", "vector rust") } }) - assertEquals(1L, client.count(name) { must { matchPhrase("title", "vector database") } }) + // matchPhrase is deliberately absent. Qdrant matches a phrase only against a text index + // created with `phrase_matching: true`, and `createPayloadIndex` cannot ask for that yet, + // so the filter is accepted and matches nothing. Asserting zero here would pin the gap as + // if it were the behaviour. assertEquals(1L, client.count(name) { must { "score" between 4.0..5.0 } }) assertEquals(1L, client.count(name) { must { valuesCount("tags", gte = 2) } }) assertEquals(1L, client.count(name) { must { hasId(PointId.num(1)) } })