LanceIndexMutationValidator.java
// 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.datasource.lance;
import org.apache.doris.analysis.IndexDef;
import org.apache.doris.catalog.ArrayType;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.PrimitiveType;
import org.apache.doris.catalog.Type;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.ErrorCode;
import org.apache.doris.common.ErrorReport;
import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition;
import com.google.common.collect.ImmutableSet;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Static FE-side validation for CREATE/DROP INDEX statements targeting Lance catalog tables.
*
* <p>These are the FE static bounds of the Lance index lifecycle design (section 2.4). Exact
* Arrow-level revalidation — fixed-size-list-ness, vector dimension, float16 vs float32, and
* num_sub_vectors divisibility — is deferred to the isolated index-build worker per design
* sections 2.4/4.2, because {@link LanceTypeConverter} erases those facts when mapping Arrow
* types to Doris types (LanceTypeConverter.java:101-106).
*/
public final class LanceIndexMutationValidator {
private static final int MAX_INDEX_NAME_BYTES = 64;
private static final Set<String> ANN_PROPERTY_KEYS = ImmutableSet.of(
"index_type", "metric", "num_partitions", "num_sub_vectors", "num_bits");
private static final Set<String> ANN_METRICS = ImmutableSet.of("l2", "cosine", "dot");
private LanceIndexMutationValidator() {
}
/**
* Validates a top-level CREATE [OR REPLACE] INDEX statement against a Lance catalog table.
* Returns normally when the statement is statically valid; the caller then decides whether
* the statement is admitted.
*/
public static void validateCreateIndex(LanceExternalCatalog catalog, LanceExternalTable table,
IndexDefinition def) throws AnalysisException {
validateCreateIndexCatalog(catalog, def);
String lanceType = def.getLanceIndexType();
if (lanceType == null) {
lanceType = def.getIndexType() == IndexDef.IndexType.ANN ? "ANN" : null;
}
if (lanceType == null) {
rejectInvalidDefinition("Lance catalog tables only support USING ANN, BTREE, or BITMAP");
}
if (def.getCols() == null || def.getCols().size() != 1) {
rejectInvalidDefinition("Lance index must be built on exactly one column");
}
validateIndexName(def.getIndexName());
String columnName = def.getCols().get(0);
Column column = table.getColumn(columnName);
if (column == null) {
rejectInvalidDefinition("Index column '" + columnName + "' does not exist");
}
if (column.isAllowNull()) {
rejectInvalidDefinition(lanceType + " index must be built on a column that is not nullable");
}
switch (lanceType) {
case "ANN":
validateAnnIndex(column, def.getProperties());
break;
case "BTREE":
validateBtreeIndex(column, def.getProperties());
break;
case "BITMAP":
validateBitmapIndex(column, def.getProperties());
break;
default:
rejectInvalidDefinition("Lance catalog tables only support USING ANN, BTREE, or BITMAP");
}
}
/**
* Rejects CREATE INDEX against a Lance REST catalog without resolving its database or table.
*/
public static void validateCreateIndexCatalog(LanceExternalCatalog catalog, IndexDefinition def)
throws AnalysisException {
if (catalog.isRestCatalogConfigured()) {
rejectUnsupportedOperation(def.isOrReplace() ? "CREATE OR REPLACE INDEX" : "CREATE INDEX",
"REST catalogs");
}
}
/**
* Validates a top-level DROP INDEX statement targeting a Lance catalog table. The REST
* rejection keeps failing fast; Directory catalogs then get the same index-name bounds as
* the CREATE path.
*/
public static void validateDropIndex(LanceExternalCatalog catalog, String indexName)
throws AnalysisException {
if (catalog.isRestCatalogConfigured()) {
rejectUnsupportedOperation("DROP INDEX", "REST catalogs");
}
validateIndexName(indexName);
}
/**
* Shared Lance index-name bounds for the CREATE and DROP paths: the name becomes the durable
* logical identity that an admitted job and its same-name fence key are built on, so
* null/empty names are rejected here instead of being masked as unsupported operations.
*/
private static void validateIndexName(String indexName) throws AnalysisException {
if (indexName == null || indexName.isEmpty()) {
rejectInvalidDefinition("index name cannot be empty");
}
if (indexName.getBytes(StandardCharsets.UTF_8).length > MAX_INDEX_NAME_BYTES) {
rejectInvalidDefinition("index name too long, the index name length at most is 64.");
}
}
private static void validateAnnIndex(Column column, Map<String, String> properties)
throws AnalysisException {
Type columnType = column.getType();
if (!(columnType instanceof ArrayType)) {
rejectInvalidDefinition("ANN index column must be array type");
}
Type itemType = ((ArrayType) columnType).getItemType();
if (!itemType.isScalarType(PrimitiveType.FLOAT)) {
rejectInvalidDefinition("ANN index column item type must be float type");
}
// Keys match case-insensitively; the normalized view below is validation-local only.
// Persisting normalized keys/values into the admitted job spec is owned by admission.
Map<String, String> lowerCaseProperties = new HashMap<>();
for (Map.Entry<String, String> entry : properties.entrySet()) {
String key = entry.getKey().toLowerCase(Locale.ROOT);
if (!ANN_PROPERTY_KEYS.contains(key)) {
rejectInvalidDefinition("Unknown property '" + entry.getKey() + "' for Lance ANN index");
}
if (lowerCaseProperties.put(key, entry.getValue()) != null) {
rejectInvalidDefinition("Duplicate property '" + entry.getKey() + "' for Lance ANN index");
}
}
String indexType = lowerCaseProperties.get("index_type");
if (indexType == null || !indexType.equalsIgnoreCase("IVF_PQ")) {
rejectInvalidDefinition("Lance ANN index requires property \"index_type\" = \"IVF_PQ\"");
}
String metric = lowerCaseProperties.get("metric");
if (metric != null && !ANN_METRICS.contains(metric.toLowerCase(Locale.ROOT))) {
rejectInvalidDefinition("metric must be one of l2, cosine, dot");
}
checkRequiredPositiveInt(lowerCaseProperties, "num_partitions");
checkRequiredPositiveInt(lowerCaseProperties, "num_sub_vectors");
String numBits = lowerCaseProperties.get("num_bits");
if (numBits != null && parsePositiveInt(numBits) != 8) {
rejectInvalidDefinition("num_bits must be 8");
}
}
private static void validateBtreeIndex(Column column, Map<String, String> properties)
throws AnalysisException {
if (!properties.isEmpty()) {
rejectInvalidDefinition("BTREE indexes do not support properties");
}
Type columnType = column.getType();
// LARGEINT (Arrow uint64) and TIMESTAMPTZ are included deliberately.
if (!columnType.isIntegerType() && !columnType.isLargeIntType()
&& !columnType.isFloatingPointType() && !columnType.isDecimalV3()
&& !columnType.isStringType() && !columnType.isDateV2()
&& !columnType.isDatetimeV2() && !columnType.isTimeStampTz()) {
rejectInvalidDefinition("BTREE index does not support column type " + columnType);
}
}
private static void validateBitmapIndex(Column column, Map<String, String> properties)
throws AnalysisException {
if (!properties.isEmpty()) {
rejectInvalidDefinition("BITMAP indexes do not support properties");
}
Type columnType = column.getType();
// LARGEINT (Arrow uint64) is integral, included here exactly as in the BTREE matrix.
if (!columnType.isBoolean() && !columnType.isIntegerType() && !columnType.isLargeIntType()
&& !columnType.isStringType() && !columnType.isDateV2()) {
rejectInvalidDefinition("BITMAP index does not support column type " + columnType);
}
}
private static void checkRequiredPositiveInt(Map<String, String> properties, String key)
throws AnalysisException {
String value = properties.get(key);
if (value == null || parsePositiveInt(value) <= 0) {
rejectInvalidDefinition(key + " must be a positive integer");
}
}
/**
* Rejects a Lance index operation with a stable client-visible error code.
*/
public static void rejectUnsupportedOperation(String operation, String target)
throws AnalysisException {
ErrorReport.reportAnalysisException(ErrorCode.ERR_LANCE_INDEX_OPERATION_NOT_SUPPORTED,
operation, target);
}
private static void rejectInvalidDefinition(String detail) throws AnalysisException {
ErrorReport.reportAnalysisException(ErrorCode.ERR_LANCE_INDEX_INVALID, detail);
}
private static int parsePositiveInt(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return -1;
}
}
}