diff --git a/.run/nb --list-drivers.run.xml b/.run/nb --list-drivers.run.xml new file mode 100644 index 000000000..55872229e --- /dev/null +++ b/.run/nb --list-drivers.run.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/.run/nb --list-scenarios.run.xml b/.run/nb --list-scenarios.run.xml new file mode 100644 index 000000000..f1a7a6734 --- /dev/null +++ b/.run/nb --list-scenarios.run.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/.run/qdrant_delete_collection_glove_25.run.xml b/.run/qdrant_delete_collection_glove_25.run.xml new file mode 100644 index 000000000..291ad5032 --- /dev/null +++ b/.run/qdrant_delete_collection_glove_25.run.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/.run/qdrant_schema_collection_glove_25.run.xml b/.run/qdrant_schema_collection_glove_25.run.xml new file mode 100644 index 000000000..d1f56cd9f --- /dev/null +++ b/.run/qdrant_schema_collection_glove_25.run.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/.run/qdrant_search_points_glove_25.run.xml b/.run/qdrant_search_points_glove_25.run.xml new file mode 100644 index 000000000..1a8e93b90 --- /dev/null +++ b/.run/qdrant_search_points_glove_25.run.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/.run/qdrant_upsert_points_glove_25.run.xml b/.run/qdrant_upsert_points_glove_25.run.xml new file mode 100644 index 000000000..02fd9db2a --- /dev/null +++ b/.run/qdrant_upsert_points_glove_25.run.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/mvn-defaults/pom.xml b/mvn-defaults/pom.xml index a51a77576..bcfbe2a0b 100644 --- a/mvn-defaults/pom.xml +++ b/mvn-defaults/pom.xml @@ -41,6 +41,8 @@ nb5 VERBOSE + + 0.8.12 ${project.artifactId} @@ -549,7 +551,7 @@ org.jacoco jacoco-maven-plugin - 0.8.10 + ${jacoco.version} prepare-agent @@ -782,7 +784,7 @@ org.jacoco jacoco-maven-plugin - 0.8.10 + ${jacoco.version} org.apache.maven.plugins diff --git a/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/MilvusSpace.java b/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/MilvusSpace.java index 0553126e4..d212c495e 100644 --- a/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/MilvusSpace.java +++ b/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/MilvusSpace.java @@ -45,7 +45,7 @@ public class MilvusSpace implements AutoCloseable { protected MilvusServiceClient client; - private final Map connections = new HashMap<>(); +// private final Map connections = new HashMap<>(); /** * Create a new MilvusSpace Object which stores all stateful contextual information needed to interact diff --git a/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/ops/MilvusCreateCollectionOp.java b/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/ops/MilvusCreateCollectionOp.java index a8d2bddda..164dc9fdd 100644 --- a/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/ops/MilvusCreateCollectionOp.java +++ b/nb-adapters/adapter-milvus/src/main/java/io/nosqlbench/adapter/milvus/ops/MilvusCreateCollectionOp.java @@ -24,7 +24,7 @@ import io.nosqlbench.adapters.api.templating.ParsedOp; public class MilvusCreateCollectionOp extends MilvusBaseOp { /** - * Create a new {@link ParsedOp} encapsulating a call to the Milvus/Zilliz client delete method + * Create a new {@link ParsedOp} encapsulating a call to the Milvus/Zilliz client create method. * * @param client The associated {@link MilvusServiceClient} used to communicate with the database * @param request The {@link CreateCollectionParam} built for this operation diff --git a/nb-adapters/adapter-milvus/src/main/resources/milvus.md b/nb-adapters/adapter-milvus/src/main/resources/milvus.md index 76e251d50..ed2563ba3 100644 --- a/nb-adapters/adapter-milvus/src/main/resources/milvus.md +++ b/nb-adapters/adapter-milvus/src/main/resources/milvus.md @@ -9,12 +9,15 @@ https://github.com/milvus-io/milvus-sdk-java. The following parameters must be supplied to the adapter at runtime in order to successfully connect to an instance of the Milvus/Zilliz database: -* token - In order to use the pinecone database you must have an account. Once the account is created you can [request +* `token` - In order to use the Milvus/Zilliz database you must have an account. Once the account is created you + can [request an api key/token](https://milvus.io/docs/users_and_roles.md#Users-and-Roles). This key will need to be provided any - time a - database connection is desired. -* uri - When an Index is created in the database the uri must be specified as well. The adapter will - use the default value of localhost:19530 if none is provided at runtime. + time a database connection is desired. Alternatively, + the api key can be stored in a file securely and referenced via the `token_file` config option pointing to the path of + the file. +* `uri` - When an index is created in the database the URI/endpoint must be specified as well. The adapter will + use the default value of `localhost:19530` if none is provided at runtime. +* `database_name` or `database` - the name of the database to use. For Zilliz, only `default` is supported. ## Op Templates diff --git a/nb-adapters/adapter-qdrant/pom.xml b/nb-adapters/adapter-qdrant/pom.xml new file mode 100644 index 000000000..639a3fdcf --- /dev/null +++ b/nb-adapters/adapter-qdrant/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + adapter-qdrant + jar + + + mvn-defaults + io.nosqlbench + ${revision} + ../../mvn-defaults + + + ${project.artifactId} + + An nosqlbench adapter driver module for the Qdrant database. + + + + + io.nosqlbench + nb-annotations + ${revision} + compile + + + io.nosqlbench + adapters-api + ${revision} + compile + + + io.grpc + grpc-protobuf + + + 1.59.0 + + + com.google.protobuf + protobuf-java-util + + + 3.24.0 + + + com.google.guava + guava + + + 30.1-jre + + + io.qdrant + client + 1.9.0 + + + diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantAdapterUtils.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantAdapterUtils.java new file mode 100644 index 000000000..1c5a7a5bd --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantAdapterUtils.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant; + +import io.qdrant.client.grpc.Points.ScoredPoint; +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.List; + +public class QdrantAdapterUtils { + + public static final String QDRANT = "qdrant"; + + public static List splitNames(String input) { + assert StringUtils.isNotBlank(input) && StringUtils.isNotEmpty(input); + return Arrays.stream(input.split("( +| *, *)")) + .filter(StringUtils::isNotBlank) + .toList(); + } + + public static List splitLongs(String input) { + assert StringUtils.isNotBlank(input) && StringUtils.isNotEmpty(input); + return Arrays.stream(input.split("( +| *, *)")) + .filter(StringUtils::isNotBlank) + .map(Long::parseLong) + .toList(); + } + + /** + * Mask the digits in the given string with '*' + * + * @param unmasked The string to mask + * @return The masked string + */ + protected static String maskDigits(String unmasked) { + assert StringUtils.isNotBlank(unmasked) && StringUtils.isNotEmpty(unmasked); + int inputLength = unmasked.length(); + StringBuilder masked = new StringBuilder(inputLength); + for (char ch : unmasked.toCharArray()) { + if (Character.isDigit(ch)) { + masked.append("*"); + } else { + masked.append(ch); + } + } + return masked.toString(); + } + + public static int[] searchPointsResponseIdNumToIntArray(List response) { + return response.stream().mapToInt(r -> ((Number) r.getId().getNum()).intValue()).toArray(); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantDriverAdapter.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantDriverAdapter.java new file mode 100644 index 000000000..49933789b --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantDriverAdapter.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant; + +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapters.api.activityimpl.OpMapper; +import io.nosqlbench.adapters.api.activityimpl.uniform.BaseDriverAdapter; +import io.nosqlbench.adapters.api.activityimpl.uniform.DriverAdapter; +import io.nosqlbench.nb.annotations.Service; +import io.nosqlbench.nb.api.components.core.NBComponent; +import io.nosqlbench.nb.api.config.standard.NBConfigModel; +import io.nosqlbench.nb.api.config.standard.NBConfiguration; +import io.nosqlbench.nb.api.labels.NBLabels; + +import java.util.function.Function; + +import static io.nosqlbench.adapter.qdrant.QdrantAdapterUtils.QDRANT; + +@Service(value = DriverAdapter.class, selector = QDRANT) +public class QdrantDriverAdapter extends BaseDriverAdapter, QdrantSpace> { + + public QdrantDriverAdapter(NBComponent parentComponent, NBLabels labels) { + super(parentComponent, labels); + } + + @Override + public OpMapper> getOpMapper() { + return new QdrantOpMapper(this); + } + + @Override + public Function getSpaceInitializer(NBConfiguration cfg) { + return (s) -> new QdrantSpace(s, cfg); + } + + @Override + public NBConfigModel getConfigModel() { + return super.getConfigModel().add(QdrantSpace.getConfigModel()); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantDriverAdapterLoader.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantDriverAdapterLoader.java new file mode 100644 index 000000000..b861c65c5 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantDriverAdapterLoader.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant; + +import io.nosqlbench.adapter.diag.DriverAdapterLoader; +import io.nosqlbench.nb.annotations.Service; +import io.nosqlbench.nb.api.components.core.NBComponent; +import io.nosqlbench.nb.api.labels.NBLabels; + +import static io.nosqlbench.adapter.qdrant.QdrantAdapterUtils.QDRANT; + +@Service(value = DriverAdapterLoader.class, selector = QDRANT) +public class QdrantDriverAdapterLoader implements DriverAdapterLoader { + @Override + public QdrantDriverAdapter load(NBComponent parent, NBLabels childLabels) { + return new QdrantDriverAdapter(parent, childLabels); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantOpMapper.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantOpMapper.java new file mode 100644 index 000000000..3e83cdf58 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantOpMapper.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant; + +import io.nosqlbench.adapter.qdrant.opdispensers.*; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.types.QdrantOpType; +import io.nosqlbench.adapters.api.activityimpl.OpDispenser; +import io.nosqlbench.adapters.api.activityimpl.OpMapper; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.nosqlbench.engine.api.templating.TypeAndTarget; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class QdrantOpMapper implements OpMapper> { + private static final Logger logger = LogManager.getLogger(QdrantOpMapper.class); + private final QdrantDriverAdapter adapter; + + /** + * Create a new QdrantOpMapper implementing the {@link OpMapper} interface. + * + * @param adapter The associated {@link QdrantDriverAdapter} + */ + public QdrantOpMapper(QdrantDriverAdapter adapter) { + this.adapter = adapter; + } + + /** + * Given an instance of a {@link ParsedOp} returns the appropriate {@link QdrantBaseOpDispenser} subclass + * + * @param op The {@link ParsedOp} to be evaluated + * @return The correct {@link QdrantBaseOpDispenser} subclass based on the op type + */ + @Override + public OpDispenser> apply(ParsedOp op) { + TypeAndTarget typeAndTarget = op.getTypeAndTarget( + QdrantOpType.class, + String.class, + "type", + "target" + ); + logger.info(() -> "Using '" + typeAndTarget.enumId + "' op type for op template '" + op.getName() + "'"); + + return switch (typeAndTarget.enumId) { + case delete_collection -> new QdrantDeleteCollectionOpDispenser(adapter, op, typeAndTarget.targetFunction); + case create_collection -> new QdrantCreateCollectionOpDispenser(adapter, op, typeAndTarget.targetFunction); + case create_payload_index -> + new QdrantCreatePayloadIndexOpDispenser(adapter, op, typeAndTarget.targetFunction); + case search_points -> new QdrantSearchPointsOpDispenser(adapter, op, typeAndTarget.targetFunction); + case upsert_points -> new QdrantUpsertPointsOpDispenser(adapter, op, typeAndTarget.targetFunction); + case count_points -> new QdrantCountPointsOpDispenser(adapter, op, typeAndTarget.targetFunction); +// default -> throw new RuntimeException("Unrecognized op type '" + typeAndTarget.enumId.name() + "' while " + +// "mapping parsed op " + op); + }; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantSpace.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantSpace.java new file mode 100644 index 000000000..32e2ce38f --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/QdrantSpace.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant; + +import io.nosqlbench.nb.api.config.standard.ConfigModel; +import io.nosqlbench.nb.api.config.standard.NBConfigModel; +import io.nosqlbench.nb.api.config.standard.NBConfiguration; +import io.nosqlbench.nb.api.config.standard.Param; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.QdrantGrpcClient; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; + +/** + * The {@code QdrantSpace} class is a context object which stores all stateful contextual information needed to interact + * with the Qdrant database instance. + * + * @see Qdrant cloud quick start guide + * @see Qdrant quick start guide + * @see Qdrant Java client + */ +public class QdrantSpace implements AutoCloseable { + private final static Logger logger = LogManager.getLogger(QdrantSpace.class); + private final String name; + private final NBConfiguration cfg; + + protected QdrantClient client; + + /** + * Create a new QdrantSpace Object which stores all stateful contextual information needed to interact + * with the Qdrant database instance. + * + * @param name The name of this space + * @param cfg The configuration ({@link NBConfiguration}) for this nb run + */ + public QdrantSpace(String name, NBConfiguration cfg) { + this.name = name; + this.cfg = cfg; + } + + public synchronized QdrantClient getClient() { + if (client == null) { + client = createClient(); + } + return client; + } + + private QdrantClient createClient() { + String uri = cfg.get("uri"); + int grpcPort = cfg.getOptional("grpc_port").map(Integer::parseInt).orElse(6334); + boolean useTls = cfg.getOptional("use_tls").map(Boolean::parseBoolean).orElse(true); + + var builder = QdrantGrpcClient.newBuilder(uri, grpcPort, useTls); + var requiredToken = cfg.getOptional("token_file") + .map(Paths::get) + .map( + tokenFilePath -> { + try { + return Files.readAllLines(tokenFilePath).getFirst(); + } catch (IOException e) { + String error = "Error while reading token from file:" + tokenFilePath; + logger.error(error, e); + throw new RuntimeException(e); + } + } + ).orElseGet( + () -> cfg.getOptional("token") + .orElseThrow(() -> new RuntimeException("You must provide either a token_file or a token to " + + "configure a Qdrant client")) + ); + builder = builder.withApiKey(requiredToken); + builder = builder.withTimeout( + Duration.ofMillis(NumberUtils.toInt(cfg.getOptional("timeout_ms").orElse("3000"))) + ); + + logger.info("{}: Creating new Qdrant Client with (masked) token [{}], uri/endpoint [{}]", + this.name, QdrantAdapterUtils.maskDigits(requiredToken), cfg.get("uri").toString()); + return new QdrantClient(builder.build()); + } + + public static NBConfigModel getConfigModel() { + return ConfigModel.of(QdrantSpace.class) + .add( + Param.optional("token_file", String.class, "the file to load the api token from") + ) + .add( + Param.defaultTo("token", "qdrant") + .setDescription("the Qdrant api token to use to connect to the database") + ) + .add( + Param.defaultTo("uri", "localhost") + .setDescription("the URI endpoint in which the database is running. Do not provide any suffix like https:// here.") + ) + .add( + Param.defaultTo("use_tls", true) + .setDescription("whether to use TLS for the connection. Defaults to true.") + ) + .add( + Param.defaultTo("timeout_ms", 3000) + .setDescription("sets the timeout in milliseconds for all requests. Defaults to 3000ms.") + ) + .add( + Param.defaultTo("grpc_port", 6443) + .setDescription("the port to use for the gRPC connection. Defaults to 6334.") + ) + .asReadOnly(); + } + + @Override + public void close() throws Exception { + if (client != null) { + client.close(); + } + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantBaseOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantBaseOpDispenser.java new file mode 100644 index 000000000..fc3937c74 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantBaseOpDispenser.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.QdrantSpace; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapters.api.activityimpl.BaseOpDispenser; +import io.nosqlbench.adapters.api.activityimpl.uniform.DriverAdapter; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.qdrant.client.QdrantClient; + +import java.util.function.LongFunction; + +public abstract class QdrantBaseOpDispenser extends BaseOpDispenser, QdrantSpace> { + + protected final LongFunction qdrantSpaceFunction; + protected final LongFunction clientFunction; + private final LongFunction> opF; + private final LongFunction paramF; + + protected QdrantBaseOpDispenser(QdrantDriverAdapter adapter, ParsedOp op, LongFunction targetF) { + super((DriverAdapter)adapter, op); + this.qdrantSpaceFunction = adapter.getSpaceFunc(op); + this.clientFunction = (long l) -> this.qdrantSpaceFunction.apply(l).getClient(); + this.paramF = getParamFunc(this.clientFunction,op,targetF); + this.opF = createOpFunc(paramF, this.clientFunction, op, targetF); + } + protected QdrantDriverAdapter getDriverAdapter() { + return (QdrantDriverAdapter) adapter; + } + + public abstract LongFunction getParamFunc( + LongFunction clientF, + ParsedOp op, + LongFunction targetF + ); + + public abstract LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, + LongFunction targetF + ); + + @Override + public QdrantBaseOp getOp(long value) { + return opF.apply(value); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCountPointsOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCountPointsOpDispenser.java new file mode 100644 index 000000000..f0f4edbed --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCountPointsOpDispenser.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.ops.QdrantCountPointsOp; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Points.CountPoints; + +import java.util.function.LongFunction; + +public class QdrantCountPointsOpDispenser extends QdrantBaseOpDispenser { + public QdrantCountPointsOpDispenser(QdrantDriverAdapter adapter, ParsedOp op, LongFunction targetFunction) { + super(adapter, op, targetFunction); + } + + @Override + public LongFunction getParamFunc( + LongFunction clientF, ParsedOp op, LongFunction targetF) { + LongFunction ebF = + l -> CountPoints.newBuilder().setCollectionName(targetF.apply(l)); + + final LongFunction lastF = ebF; + return l -> lastF.apply(l).build(); + } + + @Override + public LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, + LongFunction targetF) { + return l -> new QdrantCountPointsOp(clientF.apply(l), paramF.apply(l)); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCreateCollectionOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCreateCollectionOpDispenser.java new file mode 100644 index 000000000..e9d162ed9 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCreateCollectionOpDispenser.java @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.ops.QdrantCreateCollectionOp; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.nosqlbench.nb.api.errors.OpConfigError; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Collections.*; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.LongFunction; + +public class QdrantCreateCollectionOpDispenser extends QdrantBaseOpDispenser { + private static final Logger logger = LogManager.getLogger(QdrantCreateCollectionOpDispenser.class); + + /** + * Create a new QdrantCreateCollectionOpDispenser subclassed from {@link QdrantBaseOpDispenser}. + * + * @param adapter The associated {@link QdrantDriverAdapter} + * @param op The {@link ParsedOp} encapsulating the activity for this cycle + * @param targetFunction A LongFunction that returns the specified Qdrant Index for this Op + * @see Qdrant Create Collection. + */ + public QdrantCreateCollectionOpDispenser(QdrantDriverAdapter adapter, + ParsedOp op, + LongFunction targetFunction) { + super(adapter, op, targetFunction); + } + + @Override + public LongFunction getParamFunc( + LongFunction clientF, + ParsedOp op, + LongFunction targetF + ) { + LongFunction ebF = + l -> CreateCollection.newBuilder().setCollectionName(targetF.apply(l)); + + LongFunction> namedVectorParamsMap = buildNamedVectorsStruct(op); + final LongFunction namedVectorsF = ebF; + ebF = l -> namedVectorsF.apply(l).setVectorsConfig(VectorsConfig.newBuilder().setParamsMap( + VectorParamsMap.newBuilder().putAllMap(namedVectorParamsMap.apply(l)).build())); + + ebF = op.enhanceFuncOptionally(ebF, "on_disk_payload", Boolean.class, + CreateCollection.Builder::setOnDiskPayload); + ebF = op.enhanceFuncOptionally(ebF, "shard_number", Number.class, + (CreateCollection.Builder b, Number n) -> b.setShardNumber(n.intValue())); + ebF = op.enhanceFuncOptionally(ebF, "replication_factor", Number.class, + (CreateCollection.Builder b, Number n) -> b.setReplicationFactor(n.intValue())); + ebF = op.enhanceFuncOptionally(ebF, "write_consistency_factor", Number.class, + (CreateCollection.Builder b, Number n) -> b.setWriteConsistencyFactor(n.intValue())); + ebF = op.enhanceFuncOptionally(ebF, "init_from", String.class, + CreateCollection.Builder::setInitFromCollection); + ebF = op.enhanceFuncOptionally(ebF, "sharding_method", String.class, + (CreateCollection.Builder b, String s) -> b.setShardingMethod(ShardingMethod.valueOf(s))); + + Optional> walF = op.getAsOptionalFunction("wal_config", Map.class); + if (walF.isPresent()) { + final LongFunction wallFunc = ebF; + LongFunction wcdF = buildWalConfigDiff(walF.get()); + ebF = l -> wallFunc.apply(l).setWalConfig(wcdF.apply(l)); + } + + Optional> optConDifF = op.getAsOptionalFunction("optimizers_config", Map.class); + if (optConDifF.isPresent()) { + final LongFunction wallFunc = ebF; + LongFunction ocdF = buildOptimizerConfigDiff(optConDifF.get()); + ebF = l -> wallFunc.apply(l).setOptimizersConfig(ocdF.apply(l)); + } + + Optional> hnswConfigDiffF = op.getAsOptionalFunction("hnsw_config", Map.class); + if (hnswConfigDiffF.isPresent()) { + final LongFunction hnswConfigF = ebF; + LongFunction hcdF = buildHnswConfigDiff(hnswConfigDiffF.get()); + ebF = l -> hnswConfigF.apply(l).setHnswConfig(hcdF.apply(l)); + } + + Optional> quantConfigF = op.getAsOptionalFunction("quantization_config", Map.class); + if (quantConfigF.isPresent()) { + final LongFunction qConF = ebF; + LongFunction qcDiffF = buildQuantizationConfig(quantConfigF.get()); + ebF = l -> qConF.apply(l).setQuantizationConfig(qcDiffF.apply(l)); + } + + Optional> sparseVectorsF = op.getAsOptionalFunction("sparse_vectors", Map.class); + if (sparseVectorsF.isPresent()) { + final LongFunction sparseVecF = ebF; + LongFunction sparseVectorsMap = buildSparseVectorsStruct(sparseVectorsF.get()); + ebF = l -> sparseVecF.apply(l).setSparseVectorsConfig(sparseVectorsMap.apply(l)); + } + + final LongFunction lastF = ebF; + return l -> lastF.apply(l).build(); + } + + /** + * Build the {@link OptimizersConfigDiff} from the provided {@link ParsedOp}. + * + * @param ocdMapLongFunc {@link LongFunction} containing the optimizer config data. + * @return {@link OptimizersConfigDiff} containing the optimizer config data + */ + private LongFunction buildOptimizerConfigDiff(LongFunction ocdMapLongFunc) { + return l -> { + OptimizersConfigDiff.Builder ocDiffBuilder = OptimizersConfigDiff.newBuilder(); + ocdMapLongFunc.apply(l).forEach((key, value) -> { + if (key.equals("deleted_threshold")) { + ocDiffBuilder.setDeletedThreshold(((Number) value).doubleValue()); + } + if (key.equals("vacuum_min_vector_number")) { + ocDiffBuilder.setVacuumMinVectorNumber(((Number) value).longValue()); + } + if (key.equals("default_segment_number")) { + ocDiffBuilder.setDefaultSegmentNumber(((Number) value).longValue()); + } + if (key.equals("max_segment_size")) { + ocDiffBuilder.setMaxSegmentSize(((Number) value).longValue()); + } + if (key.equals("memmap_threshold")) { + ocDiffBuilder.setMemmapThreshold(((Number) value).longValue()); + } + if (key.equals("indexing_threshold")) { + ocDiffBuilder.setIndexingThreshold(((Number) value).longValue()); + } + if (key.equals(("flush_interval_sec"))) { + ocDiffBuilder.setFlushIntervalSec(((Number) value).longValue()); + } + if (key.equals("max_optimization_threads")) { + ocDiffBuilder.setMaxOptimizationThreads(((Number) value).intValue()); + } + } + ); + return ocDiffBuilder.build(); + }; + } + + /** + * Build the {@link WalConfigDiff} from the provided {@link ParsedOp}. + * + * @param mapLongFunction {@link LongFunction} containing the WAL config data. + * @return {@link LongFunction} containing the WAL config data + */ + private LongFunction buildWalConfigDiff(LongFunction mapLongFunction) { + return l -> { + WalConfigDiff.Builder walConfigDiffBuilder = WalConfigDiff.newBuilder(); + mapLongFunction.apply(l).forEach((key, value) -> { + if (key.equals("wal_capacity_mb")) { + walConfigDiffBuilder.setWalCapacityMb(((Number) value).longValue()); + } + if (key.equals("wal_segments_ahead")) { + walConfigDiffBuilder.setWalSegmentsAhead(((Number) value).longValue()); + } + } + ); + return walConfigDiffBuilder.build(); + }; + } + + /** + * Only named vectors are supported at this time in this driver. + * + * @param {@link ParsedOp} op + * @return {@link LongFunction>} containing the named vectors + */ + private LongFunction> buildNamedVectorsStruct(ParsedOp op) { + if (!op.isDefined("vectors")) { + throw new OpConfigError("Must provide values for 'vectors' in 'create_collection' op"); + } + Optional> baseFunc = op.getAsOptionalFunction("vectors", Map.class); + return baseFunc.>>map(mapLongFunc -> l -> { + Map nvMap = mapLongFunc.apply(l); + Map namedVectors = new HashMap<>(); + nvMap.forEach((name, value) -> { + VectorParams.Builder builder = VectorParams.newBuilder(); + if (value instanceof Map) { + ((Map) value).forEach((innerKey, innerValue) -> { + if (innerKey.equals("distance_value")) { + builder.setDistanceValue(((Number) innerValue).intValue()); + } + if (innerKey.equals("size")) { + builder.setSize(((Number) innerValue).longValue()); + } + if (innerKey.equals("on_disk")) { + builder.setOnDisk((Boolean) innerValue); + } + if (innerKey.equals("datatype_value")) { + builder.setDatatypeValue(((Number) innerValue).intValue()); + } + if (innerKey.equals("hnsw_config")) { + builder.setHnswConfig(buildHnswConfigDiff((Map) innerValue)); + } + if (innerKey.equals("quantization_config")) { + builder.setQuantizationConfig(buildQuantizationConfig((Map) innerValue)); + } + }); + } else { + throw new OpConfigError("Named vectors must be a Map>, but got " + + value.getClass().getSimpleName() + " instead for the inner value"); + } + namedVectors.put(name, builder.build()); + }); + return namedVectors; + }).orElse(null); + } + + private LongFunction buildQuantizationConfig(LongFunction quantConfMapLongFunc) { + return l -> this.buildQuantizationConfig(quantConfMapLongFunc.apply(l)); + } + + private QuantizationConfig buildQuantizationConfig(Map quantConfMap) { + QuantizationConfig.Builder qcBuilder = QuantizationConfig.newBuilder(); + quantConfMap.forEach((key, value) -> { + switch (key) { + case "binary" -> { + BinaryQuantization.Builder binaryBuilder = BinaryQuantization.newBuilder(); + Map binaryQCData = (Map) value; + if (null != binaryQCData && !binaryQCData.isEmpty()) { + if (binaryQCData.containsKey("always_ram")) { + binaryBuilder.setAlwaysRam((Boolean) binaryQCData.get("always_ram")); + } + qcBuilder.setBinary(binaryBuilder); + } + } + case "product" -> { + ProductQuantization.Builder productBuilder = ProductQuantization.newBuilder(); + Map productQCData = (Map) value; + if (null != productQCData && !productQCData.isEmpty()) { + // Mandatory field + productBuilder.setAlwaysRam((Boolean) productQCData.get("always_ram")); + // Optional field(s) below + if (productQCData.containsKey("compression")) { + productBuilder.setCompression(CompressionRatio.valueOf((String) productQCData.get("compression"))); + } + qcBuilder.setProduct(productBuilder); + } + } + case "scalar" -> { + ScalarQuantization.Builder scalarBuilder = ScalarQuantization.newBuilder(); + Map scalarQCData = (Map) value; + if (null != scalarQCData && !scalarQCData.isEmpty()) { + // Mandatory field + scalarBuilder.setType(QuantizationType.forNumber(((Number) scalarQCData.get("type")).intValue())); + // Optional field(s) below + if (scalarQCData.containsKey("always_ram")) { + scalarBuilder.setAlwaysRam((Boolean) scalarQCData.get("always_ram")); + } + if (scalarQCData.containsKey("quantile")) { + scalarBuilder.setQuantile(((Number) scalarQCData.get("quantile")).floatValue()); + } + qcBuilder.setScalar(scalarBuilder); + } + } + } + }); + return qcBuilder.build(); + } + + /** + * Build the {@link HnswConfigDiff} from the provided {@link ParsedOp}. + * + * @param fieldSpec The {@link ParsedOp} containing the hnsw config data + * @return The {@link HnswConfigDiff} built from the provided {@link ParsedOp} + * @see HNSW Config + */ + @Deprecated + private HnswConfigDiff buildHnswConfigDiff(ParsedOp fieldSpec) { + HnswConfigDiff.Builder hnswConfigBuilder = HnswConfigDiff.newBuilder(); + fieldSpec.getOptionalStaticValue("hnsw_config", Map.class).ifPresent(hnswConfigData -> { + if (hnswConfigData.isEmpty()) { + return; + } else { + if (hnswConfigData.containsKey("ef_construct")) { + hnswConfigBuilder.setEfConstruct(((Number) hnswConfigData.get("ef_construct")).longValue()); + } + if (hnswConfigData.containsKey("m")) { + hnswConfigBuilder.setM(((Number) hnswConfigData.get("m")).intValue()); + } + if (hnswConfigData.containsKey("full_scan_threshold")) { + hnswConfigBuilder.setFullScanThreshold(((Number) hnswConfigData.get("full_scan_threshold")).intValue()); + } + if (hnswConfigData.containsKey("max_indexing_threads")) { + hnswConfigBuilder.setMaxIndexingThreads(((Number) hnswConfigData.get("max_indexing_threads")).intValue()); + } + if (hnswConfigData.containsKey("on_disk")) { + hnswConfigBuilder.setOnDisk((Boolean) hnswConfigData.get("on_disk")); + } + if (hnswConfigData.containsKey("payload_m")) { + hnswConfigBuilder.setPayloadM(((Number) hnswConfigData.get("payload_m")).intValue()); + } + } + }); + return hnswConfigBuilder.build(); + } + + private LongFunction buildHnswConfigDiff(LongFunction hnswConfigDiffMapLongFunc) { + return l -> this.buildHnswConfigDiff(hnswConfigDiffMapLongFunc.apply(l)); + } + + /** + * Build the {@link HnswConfigDiff} from the provided {@link ParsedOp}. + * + * @param hnswConfigDiffMap The {@link Map} containing the hnsw config data + * @return The {@link LongFunction} built from the provided {@link ParsedOp} + * @see HNSW Config + */ + private HnswConfigDiff buildHnswConfigDiff(Map hnswConfigDiffMap) { + HnswConfigDiff.Builder hnswConfigBuilder = HnswConfigDiff.newBuilder(); + hnswConfigDiffMap.forEach((key, value) -> { + if (key.equals("ef_construct")) { + hnswConfigBuilder.setEfConstruct(((Number) value).longValue()); + } + if (key.equals("m")) { + hnswConfigBuilder.setM(((Number) value).intValue()); + } + if (key.equals("full_scan_threshold")) { + hnswConfigBuilder.setFullScanThreshold(((Number) value).intValue()); + } + if (key.equals("max_indexing_threads")) { + hnswConfigBuilder.setMaxIndexingThreads(((Number) value).intValue()); + } + if (key.equals("on_disk")) { + hnswConfigBuilder.setOnDisk((Boolean) value); + } + if (key.equals("payload_m")) { + hnswConfigBuilder.setPayloadM(((Number) value).intValue()); + } + } + ); + return hnswConfigBuilder.build(); + } + + /** + * Build the {@link SparseVectorConfig} from the provided {@link ParsedOp}. + * + * @param sparseVectorsMapLongFunc The {@link LongFunction} containing the sparse vectors data + * @return The {@link LongFunction} built from the provided {@link ParsedOp}'s data + */ + private LongFunction buildSparseVectorsStruct(LongFunction sparseVectorsMapLongFunc) { + return l -> { + SparseVectorConfig.Builder builder = SparseVectorConfig.newBuilder(); + sparseVectorsMapLongFunc.apply(l).forEach((key, value) -> { + SparseVectorParams.Builder svpBuilder = SparseVectorParams.newBuilder(); + SparseIndexConfig.Builder sicBuilder = SparseIndexConfig.newBuilder(); + if (value instanceof Map) { + ((Map) value).forEach((innerKey, innerValue) -> { + if (innerKey.equals("full_scan_threshold")) { + sicBuilder.setFullScanThreshold(((Number) innerValue).intValue()); + } + if (innerKey.equals("on_disk")) { + sicBuilder.setOnDisk((Boolean) innerValue); + } + svpBuilder.setIndex(sicBuilder); + builder.putMap((String) key, svpBuilder.build()); + } + ); + } else { + throw new OpConfigError("Sparse Vectors must be a Map>, but got " + + value.getClass().getSimpleName() + " instead for the inner value"); + } + }); + return builder.build(); + }; + } + + // https://qdrant.tech/documentation/concepts/collections/#create-a-collection + @Override + public LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, + LongFunction targetF + ) { + return l -> new QdrantCreateCollectionOp(clientF.apply(l), paramF.apply(l)); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCreatePayloadIndexOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCreatePayloadIndexOpDispenser.java new file mode 100644 index 000000000..75406f86c --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantCreatePayloadIndexOpDispenser.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.ops.QdrantPayloadIndexOp; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Collections.PayloadIndexParams; + +import java.util.function.LongFunction; + +public class QdrantCreatePayloadIndexOpDispenser extends QdrantBaseOpDispenser { + public QdrantCreatePayloadIndexOpDispenser( + QdrantDriverAdapter adapter, + ParsedOp op, + LongFunction targetFunction) { + super(adapter, op, targetFunction); + } + + @Override + public LongFunction getParamFunc( + LongFunction clientF, + ParsedOp op, + LongFunction targetF) { + LongFunction ebF = + l -> PayloadIndexParams.newBuilder().setField(null, targetF.apply(l)); + return l -> ebF.apply(l).build(); + } + + @Override + public LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, + LongFunction targetF) { + return l -> new QdrantPayloadIndexOp(clientF.apply(l), paramF.apply(l)); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantDeleteCollectionOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantDeleteCollectionOpDispenser.java new file mode 100644 index 000000000..94fbd7d09 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantDeleteCollectionOpDispenser.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.ops.QdrantDeleteCollectionOp; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Collections.DeleteCollection; + +import java.util.function.LongFunction; + +public class QdrantDeleteCollectionOpDispenser extends QdrantBaseOpDispenser { + + /** + * Create a new {@link QdrantDeleteCollectionOpDispenser} subclassed from {@link QdrantBaseOpDispenser}. + * + * @param adapter The associated {@link QdrantDriverAdapter} + * @param op The {@link ParsedOp} encapsulating the activity for this cycle + * @param targetFunction A LongFunction that returns the specified Qdrant object for this Op + * @see Qdrant Delete Collection. + */ + public QdrantDeleteCollectionOpDispenser(QdrantDriverAdapter adapter, + ParsedOp op, + LongFunction targetFunction) { + super(adapter, op, targetFunction); + } + + @Override + public LongFunction getParamFunc( + LongFunction clientF, + ParsedOp op, + LongFunction targetF) { + LongFunction ebF = + l -> DeleteCollection.newBuilder().setCollectionName(targetF.apply(l)); + return l -> ebF.apply(l).build(); + } + + @Override + public LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, + LongFunction targetF) { + return l -> new QdrantDeleteCollectionOp(clientF.apply(l), paramF.apply(l)); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantSearchPointsOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantSearchPointsOpDispenser.java new file mode 100644 index 000000000..61a8419d8 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantSearchPointsOpDispenser.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.ops.QdrantSearchPointsOp; +import io.nosqlbench.adapter.qdrant.pojo.SearchPointsHelper; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.nosqlbench.nb.api.errors.OpConfigError; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.ShardKeySelectorFactory; +import io.qdrant.client.WithPayloadSelectorFactory; +import io.qdrant.client.WithVectorsSelectorFactory; +import io.qdrant.client.grpc.Points.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.LongFunction; + +public class QdrantSearchPointsOpDispenser extends QdrantBaseOpDispenser { + public QdrantSearchPointsOpDispenser(QdrantDriverAdapter adapter, ParsedOp op, LongFunction targetFunction) { + super(adapter, op, targetFunction); + } + + @Override + public LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, LongFunction targetF) { + return l -> new QdrantSearchPointsOp(clientF.apply(l), paramF.apply(l)); + } + + @Override + public LongFunction getParamFunc( + LongFunction clientF, + ParsedOp op, + LongFunction targetF) { + LongFunction ebF = + l -> SearchPoints.newBuilder().setCollectionName(targetF.apply(l)); + + // query params here + ebF = op.enhanceFuncOptionally(ebF, "timeout", Number.class, + (SearchPoints.Builder b, Number t) -> b.setTimeout(t.longValue())); + Optional> optionalConsistencyF = op.getAsOptionalFunction("consistency", Object.class); + if (optionalConsistencyF.isPresent()) { + LongFunction consistencyFunc = ebF; + LongFunction builtConsistency = buildReadConsistency(optionalConsistencyF.get()); + ebF = l -> consistencyFunc.apply(l).setReadConsistency(builtConsistency.apply(l)); + } + + // body params here + // - required items + ebF = op.enhanceFuncOptionally(ebF, "limit", Number.class, + (SearchPoints.Builder b, Number n) -> b.setLimit(n.longValue())); + + LongFunction searchPointsHelperF = buildVectorForSearch(op); + final LongFunction detailsOfNamedVectorsF = ebF; + ebF = l -> detailsOfNamedVectorsF.apply(l) + .setVectorName(searchPointsHelperF.apply(l).getVectorName()) + .addAllVector(searchPointsHelperF.apply(l).getVectorValues()); + //.setSparseIndices(searchPointsHelperF.apply(l).getSparseIndices()); throws NPE at their driver and hence below + final LongFunction sparseIndicesF = ebF; + ebF = l -> { + SearchPoints.Builder builder = sparseIndicesF.apply(l); + if (searchPointsHelperF.apply(l).getSparseIndices() != null) { + builder.setSparseIndices(searchPointsHelperF.apply(l).getSparseIndices()); + } + return builder; + }; + + // - optional items + ebF = op.enhanceFuncOptionally(ebF, "shard_key", String.class, (SearchPoints.Builder b, String sk) -> + b.setShardKeySelector(ShardKeySelectorFactory.shardKeySelector(sk))); + ebF = op.enhanceFuncOptionally(ebF, "score_threshold", Number.class, + (SearchPoints.Builder b, Number n) -> b.setScoreThreshold(n.floatValue())); + ebF = op.enhanceFuncOptionally(ebF, "offset", Number.class, + (SearchPoints.Builder b, Number n) -> b.setOffset(n.longValue())); + + Optional> optionalWithPayloadF = op.getAsOptionalFunction("with_payload", Object.class); + if (optionalWithPayloadF.isPresent()) { + LongFunction withPayloadFunc = ebF; + LongFunction builtWithPayload = buildWithPayloadSelector(optionalWithPayloadF.get()); + ebF = l -> withPayloadFunc.apply(l).setWithPayload(builtWithPayload.apply(l)); + } + + Optional> optionalWithVectorF = op.getAsOptionalFunction("with_vector", Object.class); + if (optionalWithVectorF.isPresent()) { + LongFunction withVectorFunc = ebF; + LongFunction builtWithVector = buildWithVectorSelector(optionalWithVectorF.get()); + ebF = l -> withVectorFunc.apply(l).setWithVectors(builtWithVector.apply(l)); + } + + // TODO - Implement filter, params + + final LongFunction lastF = ebF; + return l -> lastF.apply(l).build(); + } + + private LongFunction buildVectorForSearch(ParsedOp op) { + if (!op.isDefined("vector")) { + throw new OpConfigError("Must provide values for 'vector'"); + } + Optional> baseFunc = op.getAsOptionalFunction("vector", List.class); + return baseFunc.>map(listLongFunction -> l -> { + List> vectorPointsList = listLongFunction.apply(l); + SearchPointsHelper searchPointsHelperBuilder = new SearchPointsHelper(); + vectorPointsList.forEach(point -> { + if (point.containsKey("name")) { + searchPointsHelperBuilder.setVectorName((String) point.get("name")); + } else { + throw new OpConfigError("Must provide values for 'name' within 'vector' field"); + } + if (point.containsKey("values")) { + searchPointsHelperBuilder.setVectorValues((List) point.get("values")); + } else { + throw new OpConfigError("Must provide values for 'values' within 'vector' field"); + } + if (point.containsKey("sparse_indices")) { + searchPointsHelperBuilder.setSparseIndices( + SparseIndices.newBuilder().addAllData((List) point.get("sparse_indices")).build()); + } + }); + return searchPointsHelperBuilder; + }).orElse(null); + } + + private LongFunction buildWithVectorSelector(LongFunction objectLongFunction) { + return l -> { + Object withVector = objectLongFunction.apply(l); + switch (withVector) { + case Boolean b -> { + return WithVectorsSelectorFactory.enable(b); + } + case List objects when objects.getFirst() instanceof String -> { + return WithVectorsSelectorFactory.include((List) withVector); + } + case null, default -> { + assert withVector != null; + throw new OpConfigError("Invalid type for with_vector specified [{}]" + + withVector.getClass().getSimpleName()); + } + } + }; + } + + private LongFunction buildWithPayloadSelector(LongFunction objectLongFunction) { + return l -> { + Object withPayload = objectLongFunction.apply(l); + switch (withPayload) { + case Boolean b -> { + return WithPayloadSelector.newBuilder().setEnable(b).build(); + } + case List objects when objects.getFirst() instanceof String -> { + return WithPayloadSelectorFactory.include((List) withPayload); + } + case Map map -> { + WithPayloadSelector.Builder withPayloadSelector = WithPayloadSelector.newBuilder(); + map.forEach((key, value) -> { + if (key.equals("include")) { + withPayloadSelector.setInclude( + PayloadIncludeSelector.newBuilder().addAllFields((List) value).build()); + } else if (key.equals("exclude")) { + withPayloadSelector.setExclude( + PayloadExcludeSelector.newBuilder().addAllFields((List) value).build()); + } else { + throw new OpConfigError("Only 'include' & 'exclude' fields for with_payload map is supported," + + " but we got [{}]" + key); + } + }); + + return withPayloadSelector.build(); + } + case null, default -> { + assert withPayload != null; + throw new OpConfigError("Invalid type for with_payload specified [{}]" + + withPayload.getClass().getSimpleName()); + } + } + }; + } + + /** + * @param objectLongFunction the {@link LongFunction} from which the consistency for search will be built. + * @return a {@link ReadConsistency} function object to be added to a Qdrant {@link UpsertPoints} request. + *

+ * This method interrogates the subsection of the ParsedOp defined for vector parameters and constructs a list of + * vector (dense plus sparse) points based on the included values, or returns null if this section is not populated. + * The base function returns either the List of vectors or null, while the interior function builds the vectors + * with a Builder pattern based on the values contained in the source ParsedOp. + */ + private LongFunction buildReadConsistency(LongFunction objectLongFunction) { + return l -> { + Object consistency = objectLongFunction.apply(l); + if (consistency instanceof Number) { + return ReadConsistency.newBuilder().setTypeValue((Integer) consistency).build(); + } else if (consistency instanceof String) { + return ReadConsistency.newBuilder().setType(ReadConsistencyType.valueOf((String) consistency)).build(); + } else { + throw new OpConfigError("Invalid type for read consistency specified"); + } + }; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantUpsertPointsOpDispenser.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantUpsertPointsOpDispenser.java new file mode 100644 index 000000000..c03f6f938 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/opdispensers/QdrantUpsertPointsOpDispenser.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.opdispensers; + +import io.nosqlbench.adapter.qdrant.QdrantDriverAdapter; +import io.nosqlbench.adapter.qdrant.ops.QdrantBaseOp; +import io.nosqlbench.adapter.qdrant.ops.QdrantUpsertPointsOp; +import io.nosqlbench.adapters.api.activityimpl.OpDispenser; +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.nosqlbench.nb.api.errors.OpConfigError; +import io.qdrant.client.*; +import io.qdrant.client.grpc.JsonWithInt.ListValue; +import io.qdrant.client.grpc.JsonWithInt.NullValue; +import io.qdrant.client.grpc.JsonWithInt.Struct; +import io.qdrant.client.grpc.JsonWithInt.Value; +import io.qdrant.client.grpc.Points.Vector; +import io.qdrant.client.grpc.Points.*; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.LongFunction; + +public class QdrantUpsertPointsOpDispenser extends QdrantBaseOpDispenser { + private static final Logger logger = LogManager.getLogger(QdrantUpsertPointsOpDispenser.class); + + /** + * Create a new {@link QdrantUpsertPointsOpDispenser} implementing the {@link OpDispenser} interface. + * @param adapter + * @param op + * @param targetFunction + * @see Upsert Points + */ + public QdrantUpsertPointsOpDispenser(QdrantDriverAdapter adapter, ParsedOp op, LongFunction targetFunction) { + super(adapter, op, targetFunction); + } + + @Override + public LongFunction getParamFunc( + LongFunction clientF, + ParsedOp op, + LongFunction targetF + ) { + LongFunction ebF = + l -> UpsertPoints.newBuilder().setCollectionName(targetF.apply(l)); + + // set wait and ordering query params + ebF = op.enhanceFuncOptionally(ebF, "wait", Boolean.class, UpsertPoints.Builder::setWait); + ebF = op.enhanceFuncOptionally(ebF, "ordering", Number.class, (UpsertPoints.Builder b, Number n) -> + b.setOrdering(WriteOrdering.newBuilder().setType(WriteOrderingType.forNumber(n.intValue())))); + + // request body begins here + ebF = op.enhanceFuncOptionally(ebF, "shard_key", String.class, (UpsertPoints.Builder b, String sk) -> + b.setShardKeySelector(ShardKeySelectorFactory.shardKeySelector(sk))); + LongFunction> pointsF = constructVectorPointsFunc(op); + final LongFunction pointsOfNamedVectorsF = ebF; + ebF = l -> pointsOfNamedVectorsF.apply(l).addAllPoints(pointsF.apply(l)); + + final LongFunction lastF = ebF; + return l -> lastF.apply(l).build(); + } + + /** + * @param op the {@link ParsedOp} from which the vector objects will be built + * @return an Iterable Collection of {@link PointStruct} objects to be added to a Qdrant {@link UpsertPoints} request. + *

+ * This method interrogates the subsection of the ParsedOp defined for vector parameters and constructs a list of + * vector (dense plus sparse) points based on the included values, or returns null if this section is not populated. + * The base function returns either the List of vectors or null, while the interior function builds the vectors + * with a Builder pattern based on the values contained in the source ParsedOp. + */ + private LongFunction> constructVectorPointsFunc(ParsedOp op) { + Optional> baseFunc = + op.getAsOptionalFunction("points", List.class); + return baseFunc.>>map(listLongFunction -> l -> { + List returnVectorPoints = new ArrayList<>(); + List> vectorPoints = listLongFunction.apply(l); + PointStruct.Builder pointBuilder; + for (Map point : vectorPoints) { + pointBuilder = PointStruct.newBuilder(); + // 'id' field is mandatory, if not present, server will throw an exception + PointId.Builder pointId = PointId.newBuilder(); + if (point.get("id") instanceof Number) { + pointId.setNum(((Number) point.get("id")).longValue()); + } else if (point.get("id") instanceof String) { + pointId.setUuid((String) point.get("id")); + } else { + logger.warn("Unsupported 'id' value type [{}] specified for 'points'. Ignoring.", + point.get("id").getClass().getSimpleName()); + } + pointBuilder.setId(pointId); + if (point.containsKey("payload")) { + pointBuilder.putAllPayload(getPayloadValues(point.get("payload"))); + } + pointBuilder.setVectors(VectorsFactory.namedVectors(getNamedVectorMap(point.get("vector")))); + returnVectorPoints.add(pointBuilder.build()); + } + return returnVectorPoints; + }).orElse(null); + } + + private Map getNamedVectorMap(Object rawVectorValues) { + Map namedVectorMapData; + if (rawVectorValues instanceof Map) { + namedVectorMapData = new HashMap<>(); + List sparseVectors = new ArrayList<>(); + List sparseIndices = new ArrayList<>(); + BiConsumer namedVectorsToPointsVectorValue = (nvkey, nvVal) -> { + Vector targetVectorVal; + if (nvVal instanceof Map) { + // Deal with named sparse vectors here + ((Map) nvVal).forEach( + (svKey, svValue) -> { + if ("values".equals(svKey)) { + sparseVectors.addAll((List) svValue); + } else if ("indices".equals(svKey)) { + sparseIndices.addAll((List) svValue); + } else { + logger.warn("Unrecognized sparse vector field [{}] provided. Ignoring.", svKey); + } + } + ); + targetVectorVal = VectorFactory.vector(sparseVectors, sparseIndices); + } else if (nvVal instanceof List) { + // Deal with regular named dense vectors here + targetVectorVal = VectorFactory.vector((List) nvVal); + } else + throw new RuntimeException("Unsupported 'vector' value type [" + nvVal.getClass().getSimpleName() + " ]"); + namedVectorMapData.put(nvkey, targetVectorVal); + }; + ((Map) rawVectorValues).forEach(namedVectorsToPointsVectorValue); + } else { + throw new OpConfigError("Invalid format of type" + + " [" + rawVectorValues.getClass().getSimpleName() + "] specified for 'vector'"); + } + return namedVectorMapData; + } + + private Map getPayloadValues(Object rawPayloadValues) { + if (rawPayloadValues instanceof Map) { + Map payloadMap = (Map) rawPayloadValues; + Map payloadMapData = new HashMap<>(payloadMap.size()); + payloadMap.forEach((pKey, pVal) -> { + switch (pVal) { + case Boolean b -> payloadMapData.put(pKey, ValueFactory.value(b)); + case Double v -> payloadMapData.put(pKey, ValueFactory.value(v)); + case Integer i -> payloadMapData.put(pKey, ValueFactory.value(i)); + case String s -> payloadMapData.put(pKey, ValueFactory.value(s)); + case ListValue listValue -> payloadMapData.put(pKey, ValueFactory.list((List) pVal)); + case NullValue nullValue -> payloadMapData.put(pKey, ValueFactory.nullValue()); + case Struct struct -> payloadMapData.put(pKey, Value.newBuilder().setStructValue(struct).build()); + default -> logger.warn("Unknown payload value type passed." + + " Only https://qdrant.tech/documentation/concepts/payload/#payload-types are supported." + + " {} will be ignored.", pVal.toString()); + } + }); + return payloadMapData; + } else { + throw new RuntimeException("Invalid format of type" + + " [" + rawPayloadValues.getClass().getSimpleName() + "] specified for payload"); + } + } + + /** + * Create a new {@link QdrantUpsertPointsOp} implementing the {@link QdrantBaseOp} interface. + * @see Upsert Points + */ + @Override + public LongFunction> createOpFunc( + LongFunction paramF, + LongFunction clientF, + ParsedOp op, + LongFunction targetF + ) { + return l -> new QdrantUpsertPointsOp(clientF.apply(l), paramF.apply(l)); + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantBaseOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantBaseOp.java new file mode 100644 index 000000000..3fa6ff24d --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantBaseOp.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.nosqlbench.adapters.api.activityimpl.uniform.flowtypes.CycleOp; +import io.qdrant.client.QdrantClient; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.function.LongFunction; + +public abstract class QdrantBaseOp implements CycleOp { + + protected final static Logger logger = LogManager.getLogger(QdrantBaseOp.class); + + protected final QdrantClient client; + protected final T request; + protected final LongFunction apiCall; + + public QdrantBaseOp(QdrantClient client, T requestParam) { + this.client = client; + this.request = requestParam; + this.apiCall = this::applyOp; + } + + public QdrantBaseOp(QdrantClient client, T requestParam, LongFunction call) { + this.client = client; + this.request = requestParam; + this.apiCall = call; + } + + @Override + public final Object apply(long value) { + logger.trace("applying op: {}", this); + + try { + Object result = applyOp(value); + return result; + } catch (Exception e) { + RuntimeException rte = (RuntimeException) e; + throw rte; + } + } + + public abstract Object applyOp(long value); + + @Override + public String toString() { + return "QdrantOp(" + this.request.getClass().getSimpleName() + ")"; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantCountPointsOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantCountPointsOp.java new file mode 100644 index 000000000..48f8e0ba5 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantCountPointsOp.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Points.CountPoints; + +import java.time.Duration; +import java.util.concurrent.ExecutionException; + +public class QdrantCountPointsOp extends QdrantBaseOp { + public QdrantCountPointsOp(QdrantClient client, CountPoints request) { + super(client, request); + } + + @Override + public Object applyOp(long value) { + long result; + try { + result = client.countAsync( + request.getCollectionName(), + request.getFilter(), + request.getExact(), + Duration.ofMinutes(5) // opinionated default of 5 minutes for timeout + ).get(); + logger.info("[QdrantCountPointsOp] Total vector points counted: {}", result); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + return result; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantCreateCollectionOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantCreateCollectionOp.java new file mode 100644 index 000000000..9c47ce0bf --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantCreateCollectionOp.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.nosqlbench.adapters.api.templating.ParsedOp; +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Collections.CollectionOperationResponse; +import io.qdrant.client.grpc.Collections.CreateCollection; + +import java.util.concurrent.ExecutionException; + +public class QdrantCreateCollectionOp extends QdrantBaseOp { + /** + * Create a new {@link ParsedOp} encapsulating a call to the Qdrant create collection method. + * + * @param client The associated {@link QdrantClient} used to communicate with the database + * @param request The {@link CreateCollection} built for this operation + */ + public QdrantCreateCollectionOp(QdrantClient client, CreateCollection request) { + super(client, request); + } + + @Override + public Object applyOp(long value) { + CollectionOperationResponse response = null; + try { + response = client.createCollectionAsync(request).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + return response; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantDeleteCollectionOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantDeleteCollectionOp.java new file mode 100644 index 000000000..2b56cbacc --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantDeleteCollectionOp.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Collections.CollectionOperationResponse; +import io.qdrant.client.grpc.Collections.DeleteCollection; + +import java.util.concurrent.ExecutionException; + +public class QdrantDeleteCollectionOp extends QdrantBaseOp { + public QdrantDeleteCollectionOp(QdrantClient client, DeleteCollection request) { + super(client, request); + } + + @Override + public Object applyOp(long value) { + CollectionOperationResponse response = null; + try { + response = client.deleteCollectionAsync(request.getCollectionName()).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + + return response; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantPayloadIndexOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantPayloadIndexOp.java new file mode 100644 index 000000000..c9a7e8da8 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantPayloadIndexOp.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Collections.PayloadIndexParams; + +public class QdrantPayloadIndexOp extends QdrantBaseOp { + public QdrantPayloadIndexOp(QdrantClient client, PayloadIndexParams request) { + super(client, request); + } + + @Override + public Object applyOp(long value) { + //client.createPayloadIndexAsync(PayloadIndexParams.get); + return null; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantSearchPointsOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantSearchPointsOp.java new file mode 100644 index 000000000..14e462114 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantSearchPointsOp.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Points.ScoredPoint; +import io.qdrant.client.grpc.Points.SearchPoints; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class QdrantSearchPointsOp extends QdrantBaseOp { + public QdrantSearchPointsOp(QdrantClient client, SearchPoints request) { + super(client, request); + } + + @Override + public Object applyOp(long value) { + List response = null; + try { + logger.debug("[QdrantSearchPointsOp] Cycle {} has request: {}", value, request.toString()); + response = client.searchAsync(request).get(); + if (logger.isDebugEnabled()) { + response.forEach(scoredPoint -> { + logger.debug("[QdrantSearchPointsOp] Scored Point: {}", scoredPoint.toString()); + }); + } + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + return response; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantUpsertPointsOp.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantUpsertPointsOp.java new file mode 100644 index 000000000..ec6f5bba5 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/ops/QdrantUpsertPointsOp.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.ops; + +import io.qdrant.client.QdrantClient; +import io.qdrant.client.grpc.Points.UpdateResult; +import io.qdrant.client.grpc.Points.UpsertPoints; + +import java.util.concurrent.ExecutionException; + +public class QdrantUpsertPointsOp extends QdrantBaseOp { + public QdrantUpsertPointsOp(QdrantClient client, UpsertPoints request) { + super(client, request); + } + + @Override + public Object applyOp(long value) { + UpdateResult response = null; + String responseStatus; + long responseOperationId; + try { + logger.debug("[QdrantUpsertPointsOp] Cycle {} has Request: {}", value, request.toString()); + response = client.upsertAsync(request).get(); + responseStatus = response.getStatus().toString(); + responseOperationId = response.getOperationId(); + switch(response.getStatus()) { + case Completed, Acknowledged -> + logger.trace("[QdrantUpsertPointsOp] Upsert points finished successfully." + + " [Status ({}) for Operation id ({})]", responseStatus, responseOperationId); + case UnknownUpdateStatus, ClockRejected -> + logger.error("[QdrantUpsertPointsOp] Upsert points failed with status '{}'" + + " for operation id '{}'", responseStatus, responseOperationId); + default -> + logger.error("[QdrantUpsertPointsOp] Unknown status '{}' for operation id '{}'", responseStatus, responseOperationId); + } + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + return response; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/pojo/SearchPointsHelper.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/pojo/SearchPointsHelper.java new file mode 100644 index 000000000..464e66424 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/pojo/SearchPointsHelper.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.pojo; + +import io.qdrant.client.grpc.Points.SparseIndices; + +import java.util.List; +import java.util.Objects; + +/** + * Helper class to store the vector name, vector values and sparse indices to be used for searching points. + */ +public class SearchPointsHelper { + private String vectorName; + private List vectorValues; + private SparseIndices sparseIndices; + + public SearchPointsHelper(String vectorName, List vectorValues, SparseIndices sparseIndices) { + this.vectorName = vectorName; + this.vectorValues = vectorValues; + this.sparseIndices = sparseIndices; + } + + public SearchPointsHelper() { + } + + public String getVectorName() { + return vectorName; + } + + public void setVectorName(String vectorName) { + this.vectorName = vectorName; + } + + public List getVectorValues() { + return vectorValues; + } + + public void setVectorValues(List vectorValues) { + this.vectorValues = vectorValues; + } + + public SparseIndices getSparseIndices() { + return sparseIndices; + } + + public void setSparseIndices(SparseIndices sparseIndices) { + this.sparseIndices = sparseIndices; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SearchPointsHelper that = (SearchPointsHelper) o; + return getVectorName().equals(that.getVectorName()) && getVectorValues().equals(that.getVectorValues()) && Objects.equals(getSparseIndices(), that.getSparseIndices()); + } + + @Override + public int hashCode() { + int result = getVectorName().hashCode(); + result = 31 * result + getVectorValues().hashCode(); + result = 31 * result + Objects.hashCode(getSparseIndices()); + return result; + } +} diff --git a/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/types/QdrantOpType.java b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/types/QdrantOpType.java new file mode 100644 index 000000000..49ab8e879 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/java/io/nosqlbench/adapter/qdrant/types/QdrantOpType.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020-2024 nosqlbench + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nosqlbench.adapter.qdrant.types; + +public enum QdrantOpType { + create_collection, + delete_collection, + create_payload_index, + // https://qdrant.github.io/qdrant/redoc/index.html#tag/points/operation/search_points + search_points, + // https://qdrant.tech/documentation/concepts/points/ + // https://qdrant.github.io/qdrant/redoc/index.html#tag/points/operation/upsert_points + upsert_points, + // https://qdrant.github.io/qdrant/redoc/index.html#tag/points/operation/count_points + // https://qdrant.tech/documentation/concepts/points/#counting-points + count_points, +} diff --git a/nb-adapters/adapter-qdrant/src/main/resources/activities/qdrant_vectors_live.yaml b/nb-adapters/adapter-qdrant/src/main/resources/activities/qdrant_vectors_live.yaml new file mode 100644 index 000000000..547c15373 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/resources/activities/qdrant_vectors_live.yaml @@ -0,0 +1,186 @@ +min_version: 5.21 +description: | + This is a template for live vector search testing. + Template Variables: + + schema: Install the schema required to run the test + rampup: Measure how long it takes to load a set of embeddings + search: Measure how the system responds to queries while it + is indexing recently ingested data. + search: Run vector search with a set of default (or overridden) parameters + In all of these phases, it is important to instance the metrics with distinct names. + Also, aggregates of recall should include total aggregate as well as a moving average. + +scenarios: + qdrant_vectors: + delete_collection: >- + run tags==block:delete_collection + errors===stop + cycles===UNDEF threads===UNDEF + uri=TEMPLATE(qdranthost) grpc_port=TEMPLATE(grpc_port,6334) token_file=TEMPLATE(token_file) + schema_collection: >- + run tags==block:schema_collection + errors===stop + cycles===UNDEF threads===UNDEF + uri=TEMPLATE(qdranthost) grpc_port=TEMPLATE(grpc_port,6334) token_file=TEMPLATE(token_file) + rampup: >- + run tags==block:rampup + errors===warn,counter,retry + cycles===TEMPLATE(train_cycles,TEMPLATE(trainsize,1000)) threads===TEMPLATE(train_threads,AUTO) + uri=TEMPLATE(qdranthost) grpc_port=TEMPLATE(grpc_port,6334) token_file=TEMPLATE(token_file) + count_vectors: >- + run tags==block:count_vectors + errors===stop + cycles===UNDEF threads===UNDEF + uri=TEMPLATE(qdranthost) grpc_port=TEMPLATE(grpc_port,6334) token_file=TEMPLATE(token_file) + search_points: >- + run tags==block:search_points + errors===warn,counter + cycles===TEMPLATE(testann_cycles,TEMPLATE(testsize,1000)) threads===TEMPLATE(testann_threads,AUTO) + uri=TEMPLATE(qdranthost) grpc_port=TEMPLATE(grpc_port,6334) token_file=TEMPLATE(token_file) + +params: + driver: qdrant + instrument: true + +bindings: + id_val: Identity(); + row_key: ToString() + row_key_batch: Mul(TEMPLATE(batch_size)L); ListSizedStepped(TEMPLATE(batch_size),long->ToString()); + # filetype=hdf5 for TEMPLATE(filetype,hdf5) + test_floatlist_hdf5: HdfFileToFloatList("testdata/TEMPLATE(dataset).hdf5", "/test"); + relevant_indices_hdf5: HdfFileToIntArray("testdata/TEMPLATE(dataset).hdf5", "/neighbors") + distance_floatlist_hdf5: HdfFileToFloatList("testdata/TEMPLATE(dataset).hdf5", "/distance") + train_floatlist_hdf5: HdfFileToFloatList("testdata/TEMPLATE(dataset).hdf5", "/train"); + train_floatlist_hdf5_batch: Mul(TEMPLATE(batch_size)L); ListSizedStepped(TEMPLATE(batch_size),HdfFileToFloatList("testdata/TEMPLATE(dataset).hdf5", "/train")); + # filetype=fvec for TEMPLATE(filetype,fvec) + test_floatlist_fvec: FVecReader("testdata/TEMPLATE(dataset)_TEMPLATE(trainsize)_query_vectors.fvec"); + relevant_indices_fvec: IVecReader("testdata/TEMPLATE(dataset)_TEMPLATE(trainsize)_indices_query.ivec"); + distance_floatlist_fvec: FVecReader("testdata/TEMPLATE(dataset)_TEMPLATE(testsize)_distances_count.fvec",TEMPLATE(dimensions),0); + train_floatlist_fvec: FVecReader("testdata/TEMPLATE(dataset)_TEMPLATE(trainsize)_base_vectors.fvec",TEMPLATE(dimensions),0); + train_floatlist_fvec_batch: Mul(TEMPLATE(batch_size,10)L); ListSizedStepped(TEMPLATE(batch_size),FVecReader("testdata/TEMPLATE(dataset)_TEMPLATE(trainsize)_base_vectors.fvec",TEMPLATE(dimensions),0)); + +blocks: + delete_collection: + ops: + # https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/delete_collection + delete_col_op: + delete_collection: "TEMPLATE(collection)" + + schema_collection: + ops: + # https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/create_collection + create_col_op: + create_collection: "TEMPLATE(collection)" + on_disk_payload: true + shard_number: 1 + replication_factor: 1 + write_consistency_factor: 1 + vectors: + value: + size: TEMPLATE(dimensions,25) + # https://github.com/qdrant/qdrant/blob/v1.9.0/lib/api/src/grpc/proto/collections.proto#L90-L96 + # 1 = Cosine, 2 = Euclid, 3 = Dot, 4 = Manhattan, 0 = UnknownDistance + distance_value: TEMPLATE(similarity_function,1) + on_disk: true + # https://github.com/qdrant/qdrant/blob/v1.9.0/lib/api/src/grpc/proto/collections.proto#L5-L9 + # 0 = Default, 1 = Float32, 2 = Uint8 + datatype_value: 1 + hnsw_config: + m: 16 + ef_construct: 100 + full_scan_threshold: 10000 + max_indexing_threads: 0 + on_disk: true + #payload_m: 16 + quantization_config: + binary: + always_ram: false + #scalar: + # # https://github.com/qdrant/qdrant/blob/v1.9.0/lib/api/src/grpc/proto/collections.proto#L117-L120 + # # 0 = UnknownQuantization, 1 = Inet8 + # type: 1 + # quantile: 0.99 + # always_ram: false + #product: + # compression: x16 + # always_ram: false + wal_config: + wal_capacity_mb: 32 + wal_segments_ahead: 0 + optimizer_config: + deleted_threshold: 0.2 + vacuum_min_vector_number: 1000 + default_segment_number: 0 + indexing_threshold: 20000 + flush_interval_sec: 5 + #sparse_vectors: + # svec1: + # full_scan_threshold: 100 + # on_disk: true + + rampup: + ops: + upsert_points_op: + upsert_points: "TEMPLATE(collection)" + wait: TEMPLATE(upsert_point_wait,true) + # https://github.com/qdrant/qdrant/blob/v1.9.0/lib/api/src/grpc/proto/points.proto#L11-L15 + # 0 - Weak, 1 - Medium, 2 - Strong + ordering: TEMPLATE(upsert_point_ordering,1) + #shard_key: "{row_key}" + points: + - id: "{id_val}" + payload: + key: "{row_key}" + vector: + # For dense vectors, use the below format + value: "{train_floatlist_TEMPLATE(filetype)}" + # For sparse vectors, use the below format + #value_sv: + # indices: your array of numbers + # values: your array of floats + + search_points: + ops: + search_points_op: + search_points: "TEMPLATE(collection)" + timeout: 300 # 5 minutes + # https://github.com/qdrant/qdrant/blob/v1.9.0/lib/api/src/grpc/proto/points.proto#L21-L25 + # 0 - All, 1 - Majority, 2 - Quorum + consistency: "Quorum" + with_payload: true + with_vector: true + limit: TEMPLATE(select_limit,100) + # Another option to set with payload is as follows + # with_payload: ["key1"] + # Another option to set with payload is as follows + # with_payload: + # include: ["key1"] + # exclude: ["key2"] + vector: + - name: "value" + values: "{test_floatlist_TEMPLATE(filetype)}" + #indices: "[1,7]" + verifier-init: | + relevancy= new io.nosqlbench.nb.api.engine.metrics.wrappers.RelevancyMeasures(_parsed_op); + for (int k in List.of(100)) { + relevancy.addFunction(io.nosqlbench.engine.extensions.computefunctions.RelevancyFunctions.recall("recall",k)); + relevancy.addFunction(io.nosqlbench.engine.extensions.computefunctions.RelevancyFunctions.precision("precision",k)); + relevancy.addFunction(io.nosqlbench.engine.extensions.computefunctions.RelevancyFunctions.F1("F1",k)); + relevancy.addFunction(io.nosqlbench.engine.extensions.computefunctions.RelevancyFunctions.reciprocal_rank("RR",k)); + relevancy.addFunction(io.nosqlbench.engine.extensions.computefunctions.RelevancyFunctions.average_precision("AP",k)); + } + verifier: | + // driver-specific function + actual_indices=io.nosqlbench.adapter.qdrant.QdrantAdapterUtils.searchPointsResponseIdNumToIntArray(result) + // System.out.println("actual_indices ------>>>>: " + actual_indices); + // driver-agnostic function + relevancy.accept({relevant_indices_TEMPLATE(filetype)},actual_indices); + // because we are "verifying" although this needs to be reorganized + return true; + + count_vectors: + ops: + count_points_op: + count_points: "TEMPLATE(collection)" + exact: true diff --git a/nb-adapters/adapter-qdrant/src/main/resources/qdrant.md b/nb-adapters/adapter-qdrant/src/main/resources/qdrant.md new file mode 100644 index 000000000..155cc2c57 --- /dev/null +++ b/nb-adapters/adapter-qdrant/src/main/resources/qdrant.md @@ -0,0 +1,41 @@ +# qdrant driver adapter + +The qdrant driver adapter is a nb adapter for the qdrant driver, an open source Java driver for connecting to and +performing operations on an instance of a Qdrant Vector database. The driver is hosted on GitHub at +https://github.com/qdrant/java-client. + +## activity parameters + +The following parameters must be supplied to the adapter at runtime in order to successfully connect to an +instance of the [Qdrant database](https://qdrant.tech/documentation): + +* `token` - In order to use the Qdrant database you must have an account. Once the account is created you can [request + an api key/token](https://qdrant.tech/documentation/cloud/authentication/). This key will need to be provided any + time a database connection is desired. Alternatively, the api key can be stored in a file securely and referenced via + the `token_file` config option pointing to the path of the file. +* `uri` - When a collection/index is created in the database the URI (aka endpoint) must be specified as well. The adapter will + use the default value of `localhost:6334` if none is provided at runtime. Remember to *not* provide the `https://` + suffix. +* `grpc_port` - the GRPC port used by the Qdrant database. Defaults to `6334`. +* `use_tls` - option to leverage TLS for the connection. Defaults to `true`. +* `timeout_ms` - sets the timeout in milliseconds for all requests. Defaults to `3000`ms. + +## Op Templates + +The Qdrant adapter supports [**all operations**](../java/io/nosqlbench/adapter/qdrant/ops) supported by the [Java +driver published by Qdrant](https://github.com/qdrant/java-client). The official Qdrant API reference can be found at +https://qdrant.github.io/java-client/io/qdrant/client/package-summary.html + +The operations include a full-fledged support for key APIs available in the Qdrant Java driver. +The following are a couple high level API operations. + +* Create Collection +* Count Points +* Drop Collection +* Search Points (vectors) + +## Examples + +Check out the [full example available here](activities/qdrant_vectors_live.yaml). + +--- diff --git a/nb-adapters/nb-adapters-included/pom.xml b/nb-adapters/nb-adapters-included/pom.xml index 5c108177c..182283b08 100644 --- a/nb-adapters/nb-adapters-included/pom.xml +++ b/nb-adapters/nb-adapters-included/pom.xml @@ -238,6 +238,20 @@ + + + adapter-qdrant-include + + false + + + + io.nosqlbench + adapter-qdrant + ${revision} + + + diff --git a/nb-adapters/pom.xml b/nb-adapters/pom.xml index 08af473e8..04d0bfcd2 100644 --- a/nb-adapters/pom.xml +++ b/nb-adapters/pom.xml @@ -1,5 +1,5 @@