This is an automated email from the ASF dual-hosted git repository. yiguolei pushed a commit to branch branch-4.2 in repository https://gitbox.apache.org/repos/asf/doris.git
commit b16d11515056c141cd4dbe2b34c63e0dabb984f4 Author: Gabriel <[email protected]> AuthorDate: Sun Sep 20 14:40:51 2026 +0800 [feature](lance) Support prepared vector search on branch-4.1 (#68162) ### What problem does this PR solve? `vector_search("query_vector"=?, "top_k"=?)` currently converts the placeholders into the literal string `?`, so server-side prepared Lance vector queries fail during analysis. Preserve placeholders for `query_vector`, `top_k`, `offset`, and `filter`, and bind fresh property values on every EXECUTE. PREPARE resolves the output schema without requiring a query vector. Table, column, and search configuration remain constant. Each execution performs normal analysis and planning and resolves Lance metadata again; the retained statement never caches a bound vector or snapshot. This supports both indexed and flat searches and reuses the parsed statement. Forwarded executions preserve cached parameter types when the client omits the type table on repeated executions; the forwarding request includes the types needed by a fresh master-side PREPARE. It does not introduce an execution-plan cache or bypass the optimizer. Example with MySQL Connector/J and `useServerPrepStmts=true`: ```java try (PreparedStatement statement = connection.prepareStatement( "SELECT id, _distance FROM vector_search(" + "'table'='catalog.db.items', 'column'='embedding', " + "'query_vector'=?, 'top_k'=?, 'offset'=?, 'filter'=?)")) { for (String vector : vectors) { statement.setString(1, vector); // JSON array matching the vector column dimension. statement.setInt(2, 10); statement.setInt(3, 0); statement.setString(4, "id > 0"); try (ResultSet rows = statement.executeQuery()) { // Consume the result before executing the next vector. } } } ``` ### Release note Support server-side prepared parameters for Lance `vector_search` query vectors, top-k, offsets, and filters. ### Validation - Proxy PREPARE tests reproduce the stale planner context in both search modes before the fix; PREPARE now synchronizes the executor with the parser-created context before parameter decoding. - Parser and analyzer tests cover parameter retention, quoted question marks, case-insensitive properties, constant-only property rejection, repeated execution with different vectors and snapshots, mixed constants/parameters, and invalid or missing parameters. - A JDBC regression reuses one `ServerPreparedStatement` with A/B/A parameter changes and compares results with literal SQL for both indexed and flat searches. On live non-master FEs, it also prepares locally and forces repeated executions to the master. - A protocol test exercises five executions through the follower decoder, RPC request construction, Thrift serialization, and a fresh proxy decoder. It covers omitted type tables, unsigned values, NULL parameters across two bitmap bytes, and later parameter type changes. Local execution retains the original payload without copying it. - PREPARE and EXECUTE share single-vector and multi-vector schema validation. Tests cover schema-only PREPARE, multi-vector binding, and rejection of invalid dimensions and excessive candidate budgets at EXECUTE. The four CI FE UT failures caused by unbound-vector validation were reproduced before the fix. - Local verification: all 154 tests passed via `run-fe-ut.sh` across `MysqlPreparedStatementForwardingTest`, `MysqlConnectProcessorCursorFetchTest`, `FEOpExecutorMysqlProtocolTest`, `LancePreparedSearchTest`, `LancePreparedStatementTest`, `LanceVectorQueryTest`, `VectorSearchTableValuedFunctionTest`, `FullTextSearchTableValuedFunctionTest`, `LanceScanNodeTest`, `PrepareTest`, and `NereidsParserTest`. FE Checkstyle passed. The full external JDBC regression was also executed locally using the current PR FE build, a master FE, an observer FE, one BE, and the MinIO/Lance fixtures: 1 suite passed, 0 failures, 0 skips. It executed 12 real server-prepared queries covering direct and forwarded execution, indexed and flat search, and A/B/A parameter changes. The original script reproduced the CI String.positive() failure before fixing both JDBC URL concatenations. ### Check List (For Author) - Test - [x] Regression test - [x] Unit Test - Behavior changed: - [x] Yes. Supported `vector_search` property values can be prepared parameters. - Does this need documentation? - [x] Yes. Usage and the supported parameter scope are documented above. ### Check List (For Reviewer who merge this PR) - [ ] Confirm the release note - [ ] Confirm test cases - [ ] Confirm document - [ ] Add branch pick label --- .../doris/nereids/analyzer/UnboundTVFRelation.java | 33 ++- .../doris/nereids/parser/LogicalPlanBuilder.java | 29 ++- .../nereids/rules/analysis/BindExpression.java | 40 ++++ .../expressions/functions/table/VectorSearch.java | 9 +- .../trees/plans/commands/PrepareCommand.java | 3 + .../java/org/apache/doris/qe/ConnectContext.java | 5 + .../java/org/apache/doris/qe/FEOpExecutor.java | 25 +- .../org/apache/doris/qe/MysqlConnectProcessor.java | 10 + .../VectorSearchTableValuedFunction.java | 71 +++--- .../nereids/parser/LancePreparedStatementTest.java | 87 +++++++ .../qe/MysqlPreparedStatementForwardingTest.java | 148 ++++++++++++ .../tablefunction/LancePreparedSearchTest.java | 258 +++++++++++++++++++++ .../lance/test_lance_prepared_vector_search.groovy | 112 +++++++++ 13 files changed, 798 insertions(+), 32 deletions(-) diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTVFRelation.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTVFRelation.java index ee8212b9049..eedb2460cd9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTVFRelation.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/analyzer/UnboundTVFRelation.java @@ -21,6 +21,8 @@ import org.apache.doris.nereids.exceptions.UnboundException; import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.LogicalProperties; import org.apache.doris.nereids.properties.UnboundLogicalProperties; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.Placeholder; import org.apache.doris.nereids.trees.expressions.Properties; import org.apache.doris.nereids.trees.expressions.Slot; import org.apache.doris.nereids.trees.expressions.functions.table.TableValuedFunction; @@ -33,7 +35,11 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalRelation; import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.nereids.util.Utils; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -43,6 +49,7 @@ public class UnboundTVFRelation extends LogicalRelation implements TVFRelation, private final String functionName; private final Properties properties; + private final Map<String, Placeholder> propertyParameters; public UnboundTVFRelation(RelationId id, String functionName, Properties properties) { this(id, functionName, properties, Optional.empty(), Optional.empty()); @@ -50,7 +57,19 @@ public class UnboundTVFRelation extends LogicalRelation implements TVFRelation, public UnboundTVFRelation(RelationId id, String functionName, Properties properties, Optional<GroupExpression> groupExpression, Optional<LogicalProperties> logicalProperties) { + this(id, functionName, properties, ImmutableMap.of(), groupExpression, logicalProperties); + } + + public UnboundTVFRelation(RelationId id, String functionName, Properties properties, + Map<String, Placeholder> propertyParameters) { + this(id, functionName, properties, propertyParameters, Optional.empty(), Optional.empty()); + } + + private UnboundTVFRelation(RelationId id, String functionName, Properties properties, + Map<String, Placeholder> propertyParameters, Optional<GroupExpression> groupExpression, + Optional<LogicalProperties> logicalProperties) { super(id, PlanType.LOGICAL_UNBOUND_TVF_RELATION, groupExpression, logicalProperties); + this.propertyParameters = ImmutableMap.copyOf(propertyParameters); this.functionName = Objects.requireNonNull(functionName, "functionName can not be null"); this.properties = Objects.requireNonNull(properties, "properties can not be null"); } @@ -63,6 +82,15 @@ public class UnboundTVFRelation extends LogicalRelation implements TVFRelation, return properties; } + public Map<String, Placeholder> getPropertyParameters() { + return propertyParameters; + } + + @Override + public List<Expression> getExpressions() { + return ImmutableList.copyOf(propertyParameters.values()); + } + @Override public TableValuedFunction getFunction() { throw new UnboundException("getFunction"); @@ -85,14 +113,15 @@ public class UnboundTVFRelation extends LogicalRelation implements TVFRelation, @Override public Plan withGroupExpression(Optional<GroupExpression> groupExpression) { - return new UnboundTVFRelation(relationId, functionName, properties, groupExpression, + return new UnboundTVFRelation(relationId, functionName, properties, propertyParameters, groupExpression, Optional.of(getLogicalProperties())); } @Override public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression> groupExpression, Optional<LogicalProperties> logicalProperties, List<Plan> children) { - return new UnboundTVFRelation(relationId, functionName, properties, groupExpression, logicalProperties); + return new UnboundTVFRelation(relationId, functionName, properties, propertyParameters, + groupExpression, logicalProperties); } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java index 5f6c72d0a5a..8804be4406c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java @@ -2875,9 +2875,34 @@ public class LogicalPlanBuilder extends DorisParserBaseVisitor<Object> { return ParserUtils.withOrigin(ctx, () -> { String functionName = ctx.tvfName.getText(); - Map<String, String> map = visitPropertyItemList(ctx.properties); + Map<String, Placeholder> parameters = new LinkedHashMap<>(); + Map<String, String> map; + if ("vector_search".equalsIgnoreCase(functionName) && ctx.properties != null) { + map = new HashMap<>(); + Set<String> keys = new HashSet<>(); + for (PropertyItemContext argument : ctx.properties.properties) { + if (argument.key.constant() instanceof DorisParser.PlaceholderContext) { + throw new AnalysisException("vector_search property names must be constant"); + } + String key = parsePropertyKey(argument.key).toLowerCase(Locale.ROOT); + if (!keys.add(key)) { + throw new AnalysisException("Duplicate vector_search property: " + key); + } + if (argument.value.constant() instanceof DorisParser.PlaceholderContext) { + if (!ImmutableSet.of("query_vector", "top_k", "offset", "filter").contains(key)) { + throw new AnalysisException("vector_search property '" + key + + "' must be constant in a prepared statement"); + } + parameters.put(key, (Placeholder) visit(argument.value.constant())); + } else { + map.put(key, parsePropertyValue(argument.value)); + } + } + } else { + map = visitPropertyItemList(ctx.properties); + } LogicalPlan relation = new UnboundTVFRelation(StatementScopeIdGenerator.newRelationId(), - functionName, new Properties(map)); + functionName, new Properties(map), parameters); return withTableAlias(relation, ctx.tableAlias()); }); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java index d145e09c875..02737980a3a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java @@ -54,6 +54,7 @@ import org.apache.doris.nereids.trees.expressions.EqualTo; import org.apache.doris.nereids.trees.expressions.ExprId; import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.nereids.trees.expressions.NamedExpression; +import org.apache.doris.nereids.trees.expressions.Placeholder; import org.apache.doris.nereids.trees.expressions.Properties; import org.apache.doris.nereids.trees.expressions.Slot; import org.apache.doris.nereids.trees.expressions.SlotReference; @@ -72,6 +73,8 @@ import org.apache.doris.nereids.trees.expressions.functions.table.FullTextSearch import org.apache.doris.nereids.trees.expressions.functions.table.TableValuedFunction; import org.apache.doris.nereids.trees.expressions.functions.table.VectorSearch; import org.apache.doris.nereids.trees.expressions.literal.IntegerLikeLiteral; +import org.apache.doris.nereids.trees.expressions.literal.Literal; +import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; import org.apache.doris.nereids.trees.plans.JoinType; import org.apache.doris.nereids.trees.plans.Plan; @@ -1777,6 +1780,39 @@ public class BindExpression implements AnalysisRuleFactory { String functionName = unboundTVFRelation.getFunctionName(); Properties arguments = unboundTVFRelation.getProperties(); + if (!unboundTVFRelation.getPropertyParameters().isEmpty()) { + // The unbound plan is retained across EXECUTEs. Never overwrite its parameter slots + // or cache a bound TVF, which would retain a previous vector and Lance snapshot. + Map<String, String> boundProperties = new HashMap<>(arguments.getMap()); + for (Map.Entry<String, Placeholder> parameter : unboundTVFRelation.getPropertyParameters().entrySet()) { + String key = parameter.getKey(); + if (statementContext.isPrepareStage()) { + // These values only determine the result schema; PREPARE does not execute a search. + switch (key) { + case "top_k": + boundProperties.put(key, "1"); + break; + case "offset": + boundProperties.put(key, "0"); + break; + case "filter": + boundProperties.put(key, "true"); + break; + default: + break; + } + } else { + Expression value = statementContext.getIdToPlaceholderRealExpr() + .get(parameter.getValue().getPlaceholderId()); + if (!(value instanceof Literal) || value instanceof NullLiteral) { + throw new AnalysisException("vector_search parameter '" + key + + "' must be a non-null literal"); + } + boundProperties.put(key, ((Literal) value).getStringValue()); + } + } + arguments = new Properties(boundProperties); + } FunctionBuilder functionBuilder = functionRegistry.findFunctionBuilder(functionName, arguments); Pair<? extends Expression, ? extends BoundFunction> bindResult = functionBuilder.build(functionName, arguments); @@ -1788,6 +1824,10 @@ public class BindExpression implements AnalysisRuleFactory { sqlCacheContext.get().setCannotProcessExpression(true); } TableValuedFunction tableValuedFunction = (TableValuedFunction) bindResult.first; + if (tableValuedFunction instanceof VectorSearch && statementContext.isPrepareStage() + && unboundTVFRelation.getPropertyParameters().containsKey("query_vector")) { + tableValuedFunction = new VectorSearch(arguments, true); + } LogicalTVFRelation relation = new LogicalTVFRelation( unboundTVFRelation.getRelationId(), tableValuedFunction, ImmutableList.of()); if (!(tableValuedFunction instanceof VectorSearch) diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/VectorSearch.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/VectorSearch.java index 3fc5a1b945a..c94e754c05c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/VectorSearch.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/VectorSearch.java @@ -28,8 +28,15 @@ import java.util.Map; /** Lance vector_search relation TVF. */ public class VectorSearch extends TableValuedFunction { + private final boolean deferQueryVector; + public VectorSearch(Properties properties) { + this(properties, false); + } + + public VectorSearch(Properties properties, boolean deferQueryVector) { super(VectorSearchTableValuedFunction.NAME, properties); + this.deferQueryVector = deferQueryVector; } @Override @@ -41,7 +48,7 @@ public class VectorSearch extends TableValuedFunction { protected TableValuedFunctionIf toCatalogFunction() { try { Map<String, String> arguments = getTVFProperties().getMap(); - return new VectorSearchTableValuedFunction(arguments); + return new VectorSearchTableValuedFunction(arguments, deferQueryVector); } catch (Throwable t) { throw new AnalysisException("Can not build vector_search(): " + t.getMessage(), t); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PrepareCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PrepareCommand.java index af4230b7ff8..36b52deb8a9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PrepareCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/PrepareCommand.java @@ -114,6 +114,9 @@ public class PrepareCommand extends Command { } StatementContext statementContext = ctx.getStatementContext(); statementContext.setPrepareStage(true); + // Forwarded EXECUTE reparses SQL into a new context before reconstructing PREPARE. + // The planner must use that context before the forwarded parameter packet is decoded. + executor.setStatementContext(statementContext); List<Slot> slots; if (logicalPlan instanceof Command) { if (logicalPlan instanceof InsertIntoTableCommand diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java index 620e4427348..0f3e092130c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java @@ -291,6 +291,11 @@ public class ConnectContext { @Setter private ByteBuffer prepareExecuteBuffer; + // Snapshot of cached types omitted by the current COM_STMT_EXECUTE packet. + @Getter + @Setter + private int[] prepareExecuteTypeCodes; + // Whether the current COM_STMT_EXECUTE requested a server-side read-only cursor. private boolean cursorFetchRequested; diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java index 43a01af0530..fe0de92857d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/FEOpExecutor.java @@ -47,6 +47,7 @@ import org.apache.thrift.TException; import org.apache.thrift.transport.TTransportException; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -214,7 +215,7 @@ public class FEOpExecutor { if (ctx.getCommand() == MysqlCommand.COM_STMT_EXECUTE) { if (null != ctx.getPrepareExecuteBuffer()) { - params.setPrepareExecuteBuffer(ctx.getPrepareExecuteBuffer()); + params.setPrepareExecuteBuffer(buildPrepareExecuteBuffer()); } params.setCursorFetchRequested(ctx.isCursorFetchRequested()); } @@ -232,6 +233,28 @@ public class FEOpExecutor { return params; } + private ByteBuffer buildPrepareExecuteBuffer() { + int[] typeCodes = ctx.getPrepareExecuteTypeCodes(); + if (typeCodes == null || typeCodes.length == 0) { + return ctx.getPrepareExecuteBuffer(); + } + // Every master RPC rebuilds PREPARE without cached types. Expand only for forwarding + // so local executions do not copy potentially large vector or binary parameter values. + ByteBuffer source = ctx.getPrepareExecuteBuffer().duplicate(); + ByteBuffer forwarded = ByteBuffer.allocate(Math.addExact(source.remaining(), typeCodes.length * 2)) + .order(ByteOrder.LITTLE_ENDIAN); + byte[] nullBitmap = new byte[(typeCodes.length + 7) / 8]; + source.get(nullBitmap); + source.get(); // Replace new_params_bind_flag=0 with a complete type table. + forwarded.put(nullBitmap).put((byte) 1); + for (int typeCode : typeCodes) { + forwarded.putChar((char) typeCode); + } + forwarded.put(source); + forwarded.flip(); + return forwarded; + } + public int getStatusCode() { if (result == null || !result.isSetStatusCode()) { return ErrorCode.ERR_UNKNOWN_ERROR.getCode(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java index eb20e67bdac..150bf613356 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java @@ -131,6 +131,7 @@ public class MysqlConnectProcessor extends ConnectProcessor { if (!ctx.isProxy()) { // An empty buffer still identifies a zero-parameter COM_STMT_EXECUTE when forwarding. ctx.setPrepareExecuteBuffer(packetBuf.duplicate()); + ctx.setPrepareExecuteTypeCodes(null); } if (paramCount > 0) { if (LOG.isDebugEnabled()) { @@ -152,6 +153,15 @@ public class MysqlConnectProcessor extends ConnectProcessor { // rewrite with new prepared statment with type info in placeholders prepCtx.command = prepareCommand.withPlaceholders(typedPlaceholders); prepareCommand = (PrepareCommand) prepCtx.command; + } else if (!ctx.isProxy()) { + // A new master proxy cannot reuse the follower's statement-local type cache. + int[] typeCodes = new int[paramCount]; + for (int i = 0; i < paramCount; i++) { + Placeholder parameter = prepareCommand.getPlaceholders().get(i); + typeCodes[i] = parameter.getMysqlColType().getCode() + | (parameter.isUnsigned() ? MysqlColType.UNSIGNED_MASK : 0); + } + ctx.setPrepareExecuteTypeCodes(typeCodes); } // parse param data for (int i = 0; i < paramCount; ++i) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java index 270bbe83d44..c394adbe099 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/VectorSearchTableValuedFunction.java @@ -70,10 +70,15 @@ public class VectorSearchTableValuedFunction extends LanceExternalSearchTableVal public VectorSearchTableValuedFunction(Map<String, String> properties) throws AnalysisException { - super(prepare(properties)); + this(properties, false); } - private static PreparedSearch prepare(Map<String, String> properties) + public VectorSearchTableValuedFunction(Map<String, String> properties, boolean deferQueryVector) + throws AnalysisException { + super(prepare(properties, deferQueryVector)); + } + + private static PreparedSearch prepare(Map<String, String> properties, boolean deferQueryVector) throws AnalysisException { Map<String, String> params = normalizeProperties(properties, PROPERTIES, NAME); boolean useIndex = !params.containsKey(USE_INDEX) @@ -85,8 +90,14 @@ public class VectorSearchTableValuedFunction extends LanceExternalSearchTableVal common.metadata().getSchema(), required(params, COLUMN, NAME), "vector"); int vectorFieldId = useIndex ? requireLanceFieldId(common.metadata(), vectorField) : -1; - TSearchVector queryVector = parseAndEncodeQueryVector( - vectorField, required(params, QUERY_VECTOR, NAME)); + // PREPARE needs the table schema, but the vector is supplied only at EXECUTE. + TSearchVector queryVector = null; + if (deferQueryVector) { + analyzeQueryVectorField(vectorField); + } else { + queryVector = parseAndEncodeQueryVector( + vectorField, required(params, QUERY_VECTOR, NAME)); + } TVectorSearchParams vectorParams = new TVectorSearchParams() .setColumn(vectorField.getName()) @@ -95,11 +106,13 @@ public class VectorSearchTableValuedFunction extends LanceExternalSearchTableVal .setOffset(common.offset()); // Pin the planner's default on every split; Lance otherwise inherits an index metric. vectorParams.setMetric(params.containsKey(METRIC) ? parseMetric(params.get(METRIC)) : TVectorMetric.L2); - validateMultiVectorBudget(queryVector, common.topK(), common.offset(), - params.containsKey(REFINE_FACTOR) ? parsePositiveInt(params.get(REFINE_FACTOR), REFINE_FACTOR) : 1); - - if (queryVector.isSetNumVectors() && vectorParams.getMetric() == TVectorMetric.HAMMING) { - throw new AnalysisException("Lance multi-vector search supports l2, cosine, and dot metrics"); + // Query-dependent checks need the bound vector; schema-only PREPARE has no vector yet. + if (!deferQueryVector) { + validateMultiVectorBudget(queryVector, common.topK(), common.offset(), + params.containsKey(REFINE_FACTOR) ? parsePositiveInt(params.get(REFINE_FACTOR), REFINE_FACTOR) : 1); + if (queryVector.isSetNumVectors() && vectorParams.getMetric() == TVectorMetric.HAMMING) { + throw new AnalysisException("Lance multi-vector search supports l2, cosine, and dot metrics"); + } } TExternalSearchRequest searchRequest = new TExternalSearchRequest() .setSchemaVersion(1) @@ -215,23 +228,7 @@ public class VectorSearchTableValuedFunction extends LanceExternalSearchTableVal static TSearchVector parseAndEncodeQueryVector(Field field, String json) throws AnalysisException { boolean multiVector = field.getType().getTypeID() == ArrowType.ArrowTypeID.List; - Field vectorField = field; - if (multiVector) { - if (hasExtension(field) || field.getDictionary() != null || field.getChildren().size() != 1) { - throw unsupportedVectorType(field); - } - vectorField = field.getChildren().get(0); - // Lance's multi-vector distance kernels do not consult inner validity bitmaps. - if (vectorField.isNullable() || vectorField.getChildren().size() != 1) { - throw new AnalysisException("Lance multi-vector columns require non-nullable subvectors"); - } - } - VectorEncodingSpec encodingSpec = analyzeVectorField(vectorField); - if (multiVector && encodingSpec.elementType != TVectorElementType.FLOAT16 - && encodingSpec.elementType != TVectorElementType.FLOAT32 - && encodingSpec.elementType != TVectorElementType.FLOAT64) { - throw unsupportedVectorType(field); - } + VectorEncodingSpec encodingSpec = analyzeQueryVectorField(field); JsonArray values = parseQueryVector(json, field, multiVector ? -1 : encodingSpec.dimension); int numVectors = multiVector ? values.size() : 1; if (multiVector) { @@ -268,6 +265,28 @@ public class VectorSearchTableValuedFunction extends LanceExternalSearchTableVal return query; } + private static VectorEncodingSpec analyzeQueryVectorField(Field field) throws AnalysisException { + boolean multiVector = field.getType().getTypeID() == ArrowType.ArrowTypeID.List; + Field vectorField = field; + if (multiVector) { + if (hasExtension(field) || field.getDictionary() != null || field.getChildren().size() != 1) { + throw unsupportedVectorType(field); + } + vectorField = field.getChildren().get(0); + // Lance's multi-vector distance kernels do not consult inner validity bitmaps. + if (vectorField.isNullable() || vectorField.getChildren().size() != 1) { + throw new AnalysisException("Lance multi-vector columns require non-nullable subvectors"); + } + } + VectorEncodingSpec encodingSpec = analyzeVectorField(vectorField); + if (multiVector && encodingSpec.elementType != TVectorElementType.FLOAT16 + && encodingSpec.elementType != TVectorElementType.FLOAT32 + && encodingSpec.elementType != TVectorElementType.FLOAT64) { + throw unsupportedVectorType(field); + } + return encodingSpec; + } + private static VectorEncodingSpec analyzeVectorField(Field field) throws AnalysisException { if (hasExtension(field) || field.getDictionary() != null || field.getType().getTypeID() != ArrowType.ArrowTypeID.FixedSizeList diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/LancePreparedStatementTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/LancePreparedStatementTest.java new file mode 100644 index 00000000000..bf6f10b47fe --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/parser/LancePreparedStatementTest.java @@ -0,0 +1,87 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 org.apache.doris.nereids.parser; + +import org.apache.doris.nereids.analyzer.UnboundTVFRelation; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.Placeholder; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.util.MemoTestUtils; +import org.apache.doris.qe.ConnectContext; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class LancePreparedStatementTest { + private ConnectContext previous; + + @BeforeEach + public void setUp() { + previous = ConnectContext.get(); + MemoTestUtils.createStatementContext(""); + } + + @AfterEach + public void tearDown() { + ConnectContext.remove(); + if (previous != null) { + previous.setThreadLocalInfo(); + } + } + + @Test + public void testRejectParameterizedTableAndColumn() { + for (String property : new String[] {"table", "column", "use_index"}) { + Assertions.assertThrows(AnalysisException.class, () -> new NereidsParser().parseSingle( + "select * from vector_search('" + property + "'=?)")); + } + } + + @Test + public void testRejectParameterizedKeysAndDuplicateProperties() { + Assertions.assertThrows(AnalysisException.class, () -> new NereidsParser().parseSingle( + "select * from vector_search(?='value')")); + Assertions.assertThrows(AnalysisException.class, () -> new NereidsParser().parseSingle( + "select * from vector_search('query_vector'=?, 'QUERY_VECTOR'='[1,2]')")); + } + + @Test + public void testQuotedQuestionMarkRemainsLiteral() { + LogicalPlan plan = new NereidsParser().parseSingle( + "select * from vector_search('table'='catalog.db.items', " + + "'column'='embedding', 'query_vector'='[1,2]', 'filter'=\"label = '?'\")"); + UnboundTVFRelation relation = plan.<UnboundTVFRelation>collectToList( + UnboundTVFRelation.class::isInstance).get(0); + Assertions.assertEquals("label = '?'", relation.getProperties().getMap().get("filter")); + Assertions.assertTrue(relation.getExpressions().isEmpty()); + } + + @Test + public void testVectorParameterSurvivesParsing() { + LogicalPlan plan = new NereidsParser().parseSingle( + "select * from vector_search('table'='catalog.db.items', " + + "'column'='embedding', 'QUERY_VECTOR'=?)"); + UnboundTVFRelation relation = plan.<UnboundTVFRelation>collectToList( + UnboundTVFRelation.class::isInstance).get(0); + Assertions.assertTrue(relation.getExpressions().stream() + .anyMatch(expression -> expression.anyMatch(Placeholder.class::isInstance)), + "The retained TVF must keep the placeholder instead of the text '?'"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlPreparedStatementForwardingTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlPreparedStatementForwardingTest.java new file mode 100644 index 00000000000..0ab8e1eee6f --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/MysqlPreparedStatementForwardingTest.java @@ -0,0 +1,148 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 org.apache.doris.qe; + +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.MysqlColType; +import org.apache.doris.mysql.MysqlCapability; +import org.apache.doris.mysql.MysqlCommand; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.trees.expressions.Placeholder; +import org.apache.doris.nereids.trees.expressions.literal.Literal; +import org.apache.doris.nereids.trees.plans.PlaceholderId; +import org.apache.doris.nereids.trees.plans.commands.PrepareCommand; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.system.SystemInfoService; +import org.apache.doris.thrift.TMasterOpRequest; +import org.apache.doris.thrift.TNetworkAddress; + +import org.apache.thrift.TDeserializer; +import org.apache.thrift.TSerializer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MysqlPreparedStatementForwardingTest { + @Test + public void testForwardedExecutionsRetainTypesAndNullBitmap() throws Exception { + ConnectContext follower = new ConnectContext(); + follower.setCommand(MysqlCommand.COM_STMT_EXECUTE); + follower.setCurrentUserIdentity(UserIdentity.ROOT); + follower.setCapability(MysqlCapability.DEFAULT_CAPABILITY); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getSelfNode()).thenReturn(new SystemInfoService.HostInfo("127.0.0.1", 9010)); + PreparedStatementContext prepared = prepare(follower); + try (MockedConstruction<StmtExecutor> executors = Mockito.mockConstruction(StmtExecutor.class); + MockedStatic<AuditLogHelper> audit = Mockito.mockStatic(AuditLogHelper.class); + MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (int execution = 0; execution < 5; execution++) { + boolean newTypes = execution == 0 || execution == 3; + boolean wideInteger = execution >= 3; + ByteBuffer packet = packet(execution, newTypes, wideInteger); + byte[] original = Arrays.copyOf(packet.array(), packet.limit()); + new MysqlConnectProcessor(follower).handleExecute( + prepared.command, 7, prepared, packet, null); + Assertions.assertNotEquals(QueryState.MysqlStateType.ERR, follower.getState().getStateType(), + follower.getState().getErrorMessage()); + Assertions.assertEquals(packet.limit(), packet.position()); + Assertions.assertArrayEquals(original, Arrays.copyOf(packet.array(), packet.limit())); + + // Every master RPC reparses PREPARE, so it starts without cached parameter types. + ConnectContext master = new ConnectContext(null, true); + master.setCommand(MysqlCommand.COM_STMT_EXECUTE); + PreparedStatementContext proxyPrepared = prepare(master); + Assertions.assertSame(packet.array(), follower.getPrepareExecuteBuffer().array()); + TMasterOpRequest request = new FEOpExecutor(new TNetworkAddress("127.0.0.1", 9010), + prepared.command.getOriginalStmt(), follower, true).buildStmtForwardParams(); + TMasterOpRequest restored = new TMasterOpRequest(); + new TDeserializer().deserialize(restored, new TSerializer().serialize(request)); + ByteBuffer forwarded = ByteBuffer.wrap(restored.getPrepareExecuteBuffer()).order(ByteOrder.LITTLE_ENDIAN); + new MysqlConnectProcessor(master).handleExecute( + proxyPrepared.command, 7, proxyPrepared, forwarded, null); + Assertions.assertNotEquals(QueryState.MysqlStateType.ERR, master.getState().getStateType(), + master.getState().getErrorMessage()); + Assertions.assertFalse(forwarded.hasRemaining()); + Assertions.assertEquals((execution + 1) * 2, executors.constructed().size()); + for (int i = 0; i < 10; i++) { + PlaceholderId id = new PlaceholderId(i); + Literal expected = (Literal) prepared.statementContext.getIdToPlaceholderRealExpr().get(id); + Literal actual = (Literal) proxyPrepared.statementContext.getIdToPlaceholderRealExpr().get(id); + Assertions.assertEquals(expected.getDataType(), actual.getDataType()); + Assertions.assertEquals(expected.getStringValue(), actual.getStringValue()); + } + Literal integer = (Literal) proxyPrepared.statementContext.getIdToPlaceholderRealExpr() + .get(new PlaceholderId(1)); + Assertions.assertEquals(wideInteger ? "1099511627776" : "4294967295", integer.getStringValue()); + Assertions.assertEquals(!wideInteger, proxyPrepared.command.getPlaceholders().get(1).isUnsigned()); + } + } + } + + private PreparedStatementContext prepare(ConnectContext context) { + List<Placeholder> parameters = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + parameters.add(new Placeholder(new PlaceholderId(i))); + } + OriginStatement origin = new OriginStatement("select ?, ?, ?, ?, ?, ?, ?, ?, ?, ?", 0); + PrepareCommand command = new PrepareCommand("7", Mockito.mock(LogicalPlan.class), parameters, origin); + return new PreparedStatementContext(command, context, new StatementContext(context, origin), "7"); + } + + private ByteBuffer packet(int execution, boolean newTypes, boolean wideInteger) { + ByteBuffer packet = ByteBuffer.allocate(256).order(ByteOrder.LITTLE_ENDIAN); + packet.position(9); // The MySQL dispatcher has already consumed the execute header. + boolean withNulls = execution == 1; + packet.put((byte) (withNulls ? 4 : 0)); + packet.put((byte) (withNulls ? 1 : 0)); // Exercise both bytes of the null bitmap. + packet.put((byte) (newTypes ? 1 : 0)); + if (newTypes) { + packet.putChar((char) MysqlColType.MYSQL_TYPE_VARSTRING.getCode()); + packet.putChar((char) (wideInteger ? MysqlColType.MYSQL_TYPE_LONGLONG.getCode() + : MysqlColType.MYSQL_TYPE_LONG.getCode() | MysqlColType.UNSIGNED_MASK)); + for (int i = 2; i < 10; i++) { + packet.putChar((char) MysqlColType.MYSQL_TYPE_LONG.getCode()); + } + } + byte[] vector = (execution == 1 ? "[3,4]" : "[1,2]").getBytes(StandardCharsets.UTF_8); + packet.put((byte) vector.length).put(vector); + if (wideInteger) { + packet.putLong(1L << 40); + } else { + packet.putInt(-1); + } + for (int i = 2; i < 10; i++) { + if (!withNulls || (i != 2 && i != 8)) { + packet.putInt(execution + i); + } + } + packet.flip(); + packet.position(9); + return packet; + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/LancePreparedSearchTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/LancePreparedSearchTest.java new file mode 100644 index 00000000000..20e72540a7f --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/LancePreparedSearchTest.java @@ -0,0 +1,258 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 org.apache.doris.tablefunction; + +import org.apache.doris.analysis.TableName; +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.Env; +import org.apache.doris.common.Pair; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.datasource.lance.LanceExternalTable; +import org.apache.doris.datasource.lance.metadata.LanceTableAccess; +import org.apache.doris.datasource.lance.metadata.LanceTableMetadata; +import org.apache.doris.mysql.MysqlCommand; +import org.apache.doris.nereids.CascadesContext; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.glue.LogicalPlanAdapter; +import org.apache.doris.nereids.parser.NereidsParser; +import org.apache.doris.nereids.properties.PhysicalProperties; +import org.apache.doris.nereids.trees.expressions.Placeholder; +import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral; +import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; +import org.apache.doris.nereids.trees.expressions.literal.StringLiteral; +import org.apache.doris.nereids.trees.plans.commands.PrepareCommand; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; +import org.apache.doris.nereids.trees.plans.logical.LogicalTVFRelation; +import org.apache.doris.nereids.util.MemoTestUtils; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.OriginStatement; +import org.apache.doris.qe.StmtExecutor; + +import com.google.common.collect.ImmutableMap; +import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +public class LancePreparedSearchTest { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testPrepareAndRepeatedBindingUseFreshVectorAndSnapshot(boolean useIndex) { + ConnectContext previous = ConnectContext.get(); + ConnectContext context = MemoTestUtils.createConnectContext(); + AtomicLong version = new AtomicLong(42); + LanceExternalTable table = mockTable(version); + try (MockedStatic<LanceExternalSearchTableValuedFunction> lookup = Mockito.mockStatic( + LanceExternalSearchTableValuedFunction.class, Mockito.CALLS_REAL_METHODS)) { + lookup.when(() -> LanceExternalSearchTableValuedFunction.findLanceExternalTable( + Mockito.any(TableName.class))).thenReturn(table); + Pair<LogicalPlan, StatementContext> parsed = new NereidsParser().parseMultiple( + "select id, _distance from vector_search('table'='catalog.db.items', " + + "'column'='embedding', 'use_index'='" + useIndex + "', " + + "'query_vector'=?, 'top_k'=?, 'offset'=?, 'filter'=?)") + .get(0); + List<Placeholder> parameters = parsed.second.getPlaceholders(); + Assertions.assertEquals(4, parameters.size()); + StatementContext prepare = MemoTestUtils.createStatementContext(context, ""); + prepare.setPrepareStage(true); + VectorSearchTableValuedFunction prepared = analyze(parsed.first, prepare); + Assertions.assertEquals(3, prepared.getTableColumns().size()); + Assertions.assertFalse(prepared.getSearchRequest().getSearchQuery().getVectorSearch().isSetQueryVector()); + + LogicalPlan staticVector = new NereidsParser().parseMultiple( + "select * from vector_search('table'='catalog.db.items', 'column'='embedding', " + + "'query_vector'='[1,2]', 'top_k'=?)").get(0).first; + Assertions.assertTrue(analyze(staticVector, prepare).getSearchRequest() + .getSearchQuery().getVectorSearch().isSetQueryVector()); + + for (int i = 0; i < 2; i++) { + version.set(42 + i); + StatementContext execute = MemoTestUtils.createStatementContext(context, ""); + execute.getIdToPlaceholderRealExpr().put(parameters.get(0).getPlaceholderId(), + new StringLiteral(i == 0 ? "[1,2]" : "[3,4]")); + execute.getIdToPlaceholderRealExpr().put(parameters.get(1).getPlaceholderId(), new IntegerLiteral(3 + i)); + execute.getIdToPlaceholderRealExpr().put(parameters.get(2).getPlaceholderId(), new IntegerLiteral(i)); + execute.getIdToPlaceholderRealExpr().put(parameters.get(3).getPlaceholderId(), + new StringLiteral("id > " + i)); + VectorSearchTableValuedFunction function = analyze(parsed.first, execute); + Assertions.assertEquals(42 + i, function.getMetadata().getVersion()); + Assertions.assertEquals(3 + i, function.getTopK()); + Assertions.assertEquals(i, function.getOffset()); + Assertions.assertEquals("id > " + i, new String( + function.getSearchRequest().getSearchFilter().getPayload(), StandardCharsets.UTF_8)); + byte[] values = function.getSearchRequest().getSearchQuery().getVectorSearch().getQueryVector().getValues(); + Assertions.assertEquals(i == 0 ? 1.0f : 3.0f, + ByteBuffer.wrap(values).order(ByteOrder.LITTLE_ENDIAN).getFloat()); + for (String invalidVector : new String[] {"[1]", "not-json"}) { + execute.getIdToPlaceholderRealExpr().put(parameters.get(0).getPlaceholderId(), + new StringLiteral(invalidVector)); + Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + } + execute.getIdToPlaceholderRealExpr().put(parameters.get(0).getPlaceholderId(), NullLiteral.INSTANCE); + Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + execute.getIdToPlaceholderRealExpr().put(parameters.get(0).getPlaceholderId(), + new StringLiteral("[1,2]")); + execute.getIdToPlaceholderRealExpr().put(parameters.get(1).getPlaceholderId(), new IntegerLiteral(0)); + Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + execute.getIdToPlaceholderRealExpr().put(parameters.get(1).getPlaceholderId(), new IntegerLiteral(3)); + execute.getIdToPlaceholderRealExpr().put(parameters.get(2).getPlaceholderId(), new IntegerLiteral(-1)); + Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + execute.getIdToPlaceholderRealExpr().remove(parameters.get(0).getPlaceholderId()); + Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + } + } finally { + ConnectContext.remove(); + if (previous != null) { + previous.setThreadLocalInfo(); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testProxyPrepareUsesParsedStatementContext(boolean useIndex) throws Exception { + ConnectContext previous = ConnectContext.get(); + ConnectContext context = new ConnectContext(null, true); + context.setEnv(Env.getCurrentEnv()); + context.setCurrentUserIdentity(UserIdentity.ROOT); + context.setThreadLocalInfo(); + context.setCommand(MysqlCommand.COM_STMT_PREPARE); + LanceExternalTable table = mockTable(new AtomicLong(42)); + try (MockedStatic<LanceExternalSearchTableValuedFunction> lookup = Mockito.mockStatic( + LanceExternalSearchTableValuedFunction.class, Mockito.CALLS_REAL_METHODS)) { + lookup.when(() -> LanceExternalSearchTableValuedFunction.findLanceExternalTable( + Mockito.any(TableName.class))).thenReturn(table); + OriginStatement sql = new OriginStatement( + "select id, _distance from vector_search('table'='catalog.db.items', " + + "'column'='embedding', 'use_index'='" + useIndex + "', " + + "'query_vector'=?, 'top_k'=?, 'offset'=?, 'filter'=?)", 0); + StmtExecutor proxy = new StmtExecutor(context, sql, true); + StatementContext constructorContext = context.getStatementContext(); + // A forwarded EXECUTE reconstructs PREPARE before decoding its parameter packet. + Deencapsulation.invoke(proxy, "parseByNereids"); + StatementContext parsedContext = context.getStatementContext(); + Assertions.assertNotSame(constructorContext, parsedContext); + Assertions.assertTrue(parsedContext.getIdToPlaceholderRealExpr().isEmpty()); + LogicalPlanAdapter parsed = (LogicalPlanAdapter) proxy.getParsedStmt(); + PrepareCommand command = new PrepareCommand("1", parsed.getLogicalPlan(), + parsedContext.getPlaceholders(), sql); + command.run(context, proxy); + Assertions.assertNotNull(context.getPreparedStementContext("1")); + Assertions.assertEquals(4, command.placeholderCount()); + Assertions.assertEquals(2, proxy.planPrepareStatementSlots().size()); + } finally { + ConnectContext.remove(); + if (previous != null) { + previous.setThreadLocalInfo(); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testMultiVectorPrepareDefersValueValidation(boolean useIndex) { + ConnectContext previous = ConnectContext.get(); + ConnectContext context = MemoTestUtils.createConnectContext(); + LanceExternalTable table = mockTable(new AtomicLong(42), true); + try (MockedStatic<LanceExternalSearchTableValuedFunction> lookup = Mockito.mockStatic( + LanceExternalSearchTableValuedFunction.class, Mockito.CALLS_REAL_METHODS)) { + lookup.when(() -> LanceExternalSearchTableValuedFunction.findLanceExternalTable( + Mockito.any(TableName.class))).thenReturn(table); + Pair<LogicalPlan, StatementContext> parsed = new NereidsParser().parseMultiple( + "select id, _distance from vector_search('table'='catalog.db.items', " + + "'column'='embedding', 'use_index'='" + useIndex + "', " + + "'query_vector'=?, 'top_k'=?)").get(0); + StatementContext prepare = MemoTestUtils.createStatementContext(context, ""); + prepare.setPrepareStage(true); + Assertions.assertFalse(analyze(parsed.first, prepare).getSearchRequest() + .getSearchQuery().getVectorSearch().isSetQueryVector()); + StatementContext execute = MemoTestUtils.createStatementContext(context, ""); + List<Placeholder> parameters = parsed.second.getPlaceholders(); + execute.getIdToPlaceholderRealExpr().put(parameters.get(0).getPlaceholderId(), + new StringLiteral("[[1,2],[3,4]]")); + execute.getIdToPlaceholderRealExpr().put(parameters.get(1).getPlaceholderId(), new IntegerLiteral(3)); + Assertions.assertEquals(2, analyze(parsed.first, execute).getSearchRequest() + .getSearchQuery().getVectorSearch().getQueryVector().getNumVectors()); + execute.getIdToPlaceholderRealExpr().put(parameters.get(1).getPlaceholderId(), new IntegerLiteral(100001)); + Exception budget = Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + Assertions.assertTrue(budget.getMessage().contains("candidate budget")); + execute.getIdToPlaceholderRealExpr().put(parameters.get(1).getPlaceholderId(), new IntegerLiteral(3)); + execute.getIdToPlaceholderRealExpr().put(parameters.get(0).getPlaceholderId(), + new StringLiteral("[[1],[3,4]]")); + Exception dimension = Assertions.assertThrows(org.apache.doris.nereids.exceptions.AnalysisException.class, + () -> analyze(parsed.first, execute)); + Assertions.assertTrue(dimension.getMessage().contains("dimension")); + } finally { + ConnectContext.remove(); + if (previous != null) { + previous.setThreadLocalInfo(); + } + } + } + + private LanceExternalTable mockTable(AtomicLong version) { + return mockTable(version, false); + } + + private LanceExternalTable mockTable(AtomicLong version, boolean multiVector) { + LanceExternalTable table = Mockito.mock(LanceExternalTable.class); + Field vector = new Field("embedding", org.apache.arrow.vector.types.pojo.FieldType.nullable( + new ArrowType.FixedSizeList(2)), Collections.singletonList( + Field.nullable("item", new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)))); + if (multiVector) { + vector = new Field("embedding", org.apache.arrow.vector.types.pojo.FieldType.nullable(ArrowType.List.INSTANCE), + Collections.singletonList(new Field("item", + org.apache.arrow.vector.types.pojo.FieldType.notNullable(new ArrowType.FixedSizeList(2)), + vector.getChildren()))); + } + Schema schema = new Schema(Arrays.asList(Field.nullable("id", new ArrowType.Int(64, true)), vector)); + Mockito.when(table.loadMetadataForSearch()).thenAnswer(invocation -> LanceTableMetadata.createSnapshotWithIndexes( + new LanceTableAccess("s3://bucket/items.lance", Collections.emptyMap()), + version.get(), schema, Collections.emptyList(), + ImmutableMap.of("id", 0, "embedding", 1), Collections.emptyList())); + Mockito.when(table.loadBasicMetadata()).thenAnswer(invocation -> table.loadMetadataForSearch()); + return table; + } + + private VectorSearchTableValuedFunction analyze(LogicalPlan plan, StatementContext statement) { + CascadesContext cascades = CascadesContext.initContext(statement, plan, PhysicalProperties.ANY); + cascades.newAnalyzer().analyze(); + LogicalTVFRelation relation = cascades.getRewritePlan().<LogicalTVFRelation>collectToList( + LogicalTVFRelation.class::isInstance).get(0); + return (VectorSearchTableValuedFunction) relation.getFunction().getCatalogFunction(); + } +} diff --git a/regression-test/suites/external_table_p0/lance/test_lance_prepared_vector_search.groovy b/regression-test/suites/external_table_p0/lance/test_lance_prepared_vector_search.groovy new file mode 100644 index 00000000000..6d70e55b11c --- /dev/null +++ b/regression-test/suites/external_table_p0/lance/test_lance_prepared_vector_search.groovy @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +suite("test_lance_prepared_vector_search", "p0,external") { + if (!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest"))) { + logger.info("Lance prepared search requires the external MinIO fixtures") + return + } + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + sql "DROP CATALOG IF EXISTS test_lance_prepared_vector_search" + sql """CREATE CATALOG test_lance_prepared_vector_search PROPERTIES ( + "type" = "lance", + "lance.catalog.type" = "filesystem", + "warehouse" = "s3://warehouse/lance", + "s3.endpoint" = "http://${externalEnvIp}:${minioPort}", + "s3.access_key" = "admin", + "s3.secret_key" = "password", + "s3.region" = "us-east-1", + "use_path_style" = "true" + )""" + + def checkPreparedSearch = { boolean forwarded -> + def connection = context.getConnection() + connection.createStatement().withCloseable { control -> + // PREPARE must run locally; forwarding is enabled only after the statement exists. + control.execute("SET force_forward_all_queries=false") + if (forwarded) { + control.execute("SYNC") + } + sql "SET enable_file_scanner_v2 = true" + sql "SET enable_sql_cache = false" + sql "SET enable_query_cache = false" + for (String useIndex : ["false", "true"]) { + String fixedProperties = """'table'='test_lance_prepared_vector_search.doris.vs_ivf_pq_f32', + 'column'='embedding', 'metric'='l2', 'use_index'='${useIndex}', + 'nprobes'='4', 'refine_factor'='10'""" + def statement = prepareStatement("""SELECT row_id, _distance + FROM vector_search(${fixedProperties}, "query_vector"=?, "top_k"=?, "offset"=?, "filter"=?) + WHERE row_id > ? ORDER BY _distance, row_id""") + try { + assertTrue(statement instanceof com.mysql.cj.jdbc.ServerPreparedStatement) + // Reuse one server statement, including returning to the first vector after a different execution. + for (int start : [0, 1023, 0]) { + String vector = "[" + (0..<16).collect { it + start }.join(",") + "]" + int topK = start == 0 ? 3 : 2 + int offset = start == 0 ? 0 : 1 + String filter = start == 0 ? "row_id > 0" : "row_id > 1000" + statement.setString(1, vector) + statement.setInt(2, topK) + statement.setInt(3, offset) + statement.setString(4, filter) + statement.setInt(5, 0) + def expected = sql("""SELECT row_id, _distance + FROM vector_search(${fixedProperties}, "query_vector"="${vector}", + "top_k"="${topK}", "offset"="${offset}", "filter"="${filter}") + WHERE row_id > 0 ORDER BY _distance, row_id""") + assertEquals(topK, expected.size()) + if (forwarded) { + control.execute("SET force_forward_all_queries=true") + } + try { + assertEquals(expected, exec(statement)) + } finally { + if (forwarded) { + control.execute("SET force_forward_all_queries=false") + } + } + } + } finally { + statement.close() + } + } + } + } + + // Keep '+' on the preceding line so Groovy continues the expression instead of applying unary plus. + String url = getServerPrepareJdbcUrl(context.config.jdbcUrl, context.dbName) + + "&emulateUnsupportedPstmts=false" + connect(context.config.jdbcUser, context.config.jdbcPassword, url) { + checkPreparedSearch(false) + } + + def followers = sql_return_maparray("SHOW FRONTENDS").findAll { + it.IsMaster == "false" && it.Alive == "true" + } + if (followers.isEmpty()) { + logger.info("Skip Lance prepared forwarding coverage: no live non-master FE") + } + followers.each { fe -> + String followerUrl = getServerPrepareJdbcUrl( + "jdbc:mysql://${fe.Host}:${fe.QueryPort}/", context.dbName, false) + + "&emulateUnsupportedPstmts=false" + connect(context.config.jdbcUser, context.config.jdbcPassword, followerUrl) { + checkPreparedSearch(true) + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
