github-actions[bot] commented on code in PR #68988:
URL: https://github.com/apache/doris/pull/68988#discussion_r4311684744
##########
gensrc/thrift/PlanNodes.thrift:
##########
@@ -385,6 +406,10 @@ struct TPaimonFileDesc {
// paimon-rust: non-default branch name. Unset means main branch (matches
// upstream paimon commit 742da63: null-if-DEFAULT_MAIN_BRANCH).
19: optional string paimon_branch
+ // Primary-key vector (ANN) search payload for this split. When set, BE
+ // routes to the paimon-rust vector-search read path instead of the normal
+ // split read; paimon_split is set but empty in that case.
+ 20: optional TPaimonVectorPayload vector_payload;
Review Comment:
[P1] Reserve field IDs that are unused across release branches
Field 20 already means `optional string original_file_path` in
`TPaimonFileDesc` on master (`e0bf4874070ba0b6837ad93f695ea63532b77a47`). This
PR reuses it for a struct. The new
`TFileScanRangeParams.external_search_request` similarly reuses field 41,
already `map<string,string> fluss_properties` on master. These are incompatible
wire identities: mixed-version readers skip the mismatched types and lose the
payload/request instead of receiving the intended fields. Allocate fresh shared
IDs for both additions while preserving master's existing reservations; marking
them optional does not make ID reuse compatible.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -982,72 +1008,156 @@ && splitHasOrcFile((DataSplit) split)
// rust. Gate until the rust leaf has a Variant Arrow decoder.
boolean projectedVariant = desc.getSlots().stream()
.anyMatch(slot ->
PaimonUtil.containsVariant(slot.getType()));
- // Scheme capability gate: the pinned paimon-rust storage
- // dispatcher (io/storage.rs) selects the FileIO parser from the
- // table location's URI scheme, and libpaimon_c.a compiles in
- // separate COS, OBS, GCS and Azdls parsers besides the OSS and S3
- // ones. Doris normalizes every object store's credentials into
- // the AWS_* / use_path_style aliases (see the *Properties storage
- // classes), which the BE rust bridge translates only into the
- // fs.oss.* and s3.* key families — a cosn:// / obs:// / gs:// /
- // abfs:// warehouse would reach its scheme's parser without the
- // key family it reads (fs.cosn.userinfo.*, fs.obs.*, gcs.*,
- // azure.*) and fail the open instead of using JNI. Only the
- // schemes whose property translation is implemented and
- // open-tested (s3 / s3a / oss, via RUST_VERIFIED_LOCATION_SCHEMES)
- // plus the credential-free hdfs and local-filesystem parsers stay
- // rust-eligible; every other scheme falls back to JNI. A null
- // location also routes to JNI: the rust path needs the
- // paimon_table that only a real location can provide (BE rejects
- // a split without it).
- boolean schemeCapabilityVerified =
isRustVerifiedLocationScheme(source.getTableLocation());
- // An hdfs:// location is scheme-verified only together with the
credential-free
- // backend shape: the backend storage properties that ship to BE
also carry an
- // HDFS catalog's authentication (kerberos principal / keytab,
proxy user, HA
- // nameservice config), none of which the pinned rust HDFS parser
reads — the
- // scan would open as the BE process's ambient identity instead of
the
- // catalog's configured one and fail the access JNI honors. See
- // isRustVerifiedHdfsBackend.
- boolean hdfsBackendVerified =
!isHdfsLocationScheme(source.getTableLocation())
- || isRustVerifiedHdfsBackend(backendStorageProperties);
- // With merge-on-read=true the whole table already routes to JNI
(dvMergeOnRead);
- // for the remaining partial-update/aggregation DV tables, a split
that is
+ // With merge-on-read=true the whole table already routes to JNI
+ // (rustEligibility.dvMergeOnRead); for the remaining
+ // partial-update/aggregation DV tables, a split that is
// not fully materialized (uncompacted level-0 data, or
retractions not
// known to be applied — even a split with no deletion file
attached yet)
// fails the rust is_fully_materialized_pk_dv guard, so it falls
back per
// split instead of turning into a BE-open failure. nativeSplit and
// !fallbackRead only guard the cast — those splits already route
to JNI.
- boolean splitDvNotMaterialized = puAggDeletionVectors &&
!dvMergeOnRead
+ boolean splitDvNotMaterialized =
rustEligibility.puAggDeletionVectors
+ && !rustEligibility.dvMergeOnRead
&& nativeSplit && !fallbackRead
&& !isFullyMaterializedPkDvSplit((DataSplit) split);
+ boolean vectorSearchSplit = paimonSplit.getVectorPayloadBytes() !=
null;
+ // Session, shared table / storage / backend, and scan-shape
gates. The
+ // shared PaimonRustEligibility computation is also the
+ // PushDownVectorTopNIntoPaimonScan rewrite's pre-flight
+ // (PaimonVectorSearch.vectorScanEligible): the rewrite is
one-way, so both
+ // sites must agree on the same gates, and the scan node stays
authoritative
+ // — it evaluates the relation-option-processed table, so a
t@options
+ // override the raw-table pre-flight cannot see still rejects here
(loudly,
+ // as the vector path has no JNI fallback). !isIncremental and the
+ // split-half of the fallback-read gate are the same backstops: an
+ // incremental scan has no PK-vector semantics (the VectorScan
builder has
+ // no incremental mode), and a FallbackSplit shape cannot ride the
vector
+ // payload either — both fail loudly here rather than silently
returning
+ // snapshot rows for a query that asked for changes. Each gate's
value is
+ // also what the fallback logging below reports.
boolean canUseRust = sessionVariable.isEnablePaimonRustReader()
- && sessionVariable.enableFileScannerV2 && nativeSplit &&
!fallbackRead
- && !isIncremental && providerModeTranslatable &&
!queryAuthTable
- && !restTokenTable
- && !dvMergeOnRead && !splitDvNotMaterialized
- && !orcLtzSchema && !projectedVariant && !externalFileSplit
- && !deduplicateIgnoreDelete && !rustUnsupportedMergeOption
- && schemeCapabilityVerified && hdfsBackendVerified
- && paimonFileStoreTable != null;
- if (canUseRust) {
- // Branch tables can retain persisted modes without a
selector. Validate the
- // transported options too: the pinned Rust builder only
accepts default here.
- String scanMode =
PaimonScanParams.withoutTimeTravelSelectors(paimonFileStoreTable.schema())
- .options().get(CoreOptions.SCAN_MODE.key());
- canUseRust = scanMode == null ||
"default".equalsIgnoreCase(scanMode);
- }
- if (canUseRust) {
- // Every candidate can receive this range; older BEs cannot
decode reader type 3.
- canUseRust = !backendPolicy.getBackends().isEmpty()
- &&
backendPolicy.getBackends().stream().allMatch(Backend::isPaimonRustReaderSupported);
- }
- if (canUseRust) {
- if (rustReaderCapabilities == null) {
- rustReaderCapabilities = new
PaimonRustReaderCapabilities(paimonFileStoreTable, desc);
+ && sessionVariable.enableFileScannerV2
+ && rustEligibility.eligible()
+ && !fallbackRead && !isIncremental
+ && !orcLtzSchema && !projectedVariant;
+ // The remaining gates judge the DataSplit the BATCH reader opens:
the
+ // native DataSplit shape, unmaterialized partial-update /
aggregation
+ // deletion vectors, external-path files, and the
+ // PaimonRustReaderCapabilities whitelist. A PK-vector search
split is
+ // never read as a DataSplit (it rides serialized in
vector_payload and
+ // executes paimon-rust's eager Top-K chain, which applies deletion
+ // vectors and the merge inside the search itself), so it does not
+ // consult them.
+ if (!vectorSearchSplit && canUseRust) {
Review Comment:
[P1] Keep the nested-projection gate on vector materialization
With nested pruning enabled (the default), this reduced plan is accepted:
```
TopN(dis)
Project(struct_element(payload,'b'), dis := l2_distance_approximate(vec,q))
PaimonScan(payload ROW<a INT,b INT>, indexed vec)
```
The scan slot is pruned to `ROW<b INT>`, but vector materialization projects
only the root name `payload` and returns the full Arrow `ROW<a,b>`. Skipping
`PaimonRustReaderCapabilities.canRead` also skips `hasFullNestedProjection`;
`DataTypeStructSerDe::read_column_from_arrow` then binds child 0 by ordinal,
returning `a` as `b` in release builds (or failing its field-count DCHECK in
debug). The ordinary path explicitly falls back for this shape. Check
projection compatibility before committing the rewrite, or carry a recursive
projection into the vector reader. This is a distinct instance from the prior
VARIANT thread: an ordinary STRUCT is decoded with the wrong child layout.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -1413,6 +1531,87 @@ public Map<String, String> getIncrReadParams() throws
UserException {
return paimonScanParams;
}
+ /**
+ * Plan a primary-key vector (ANN) search. Produces one Doris split per
+ * (partition, bucket) {@link BucketVectorSearchSplit}, serialized with the
+ * native "PKVSPLIT"/v1 protocol so paimon-rust on BE can decode it.
Planning
+ * only needs the vector column and the partition-half of the predicate;
the
+ * query vector and data (residual) predicates are handled by BE.
+ */
+ @VisibleForTesting
+ public List<Split> getVectorSearchSplits() throws UserException {
+ Table paimonTable = getProcessedTable();
+ // Only the partition-half of the predicate is pushed into Paimon
planning.
+ // The data (residual) half is left for BE's vector-search filter, and
passing
+ // it here would trip PrimaryKeyVectorScan's DV/merge-on-read
pre-filter guard.
+ PartitionPredicate partitionPredicate = null;
+ if (predicates != null && !predicates.isEmpty()) {
+ Pair<Optional<PartitionPredicate>, List<Predicate>> partSplit =
+
PartitionPredicate.splitPartitionPredicatesAndDataPredicates(
+ predicates, paimonTable.rowType(),
paimonTable.partitionKeys());
+ partitionPredicate = partSplit.getLeft().orElse(null);
+ }
+
+ // Resolve the actual Paimon field name (case-insensitive); the
pushed-down
+ // column name comes from the Doris column, which may differ in case.
+ String vectorColumn = resolvePaimonFieldName(paimonTable,
annSortColumnName);
+
+ VectorScan.Plan plan;
+ try {
+ plan = paimonTable.newBatchVectorSearchBuilder()
+ .withVectorColumn(vectorColumn)
+ .withPartitionFilter(partitionPredicate)
+ .newVectorScan()
+ .scan();
+ } catch (Exception e) {
+ throw new UserException("Paimon PK-vector search planning failed:
" + e.getMessage(), e);
+ }
+
+ List<Split> splits = new ArrayList<>();
+ Set<BinaryRow> partitions = new HashSet<>();
+ for (VectorSearchSplit vectorSplit : plan.splits()) {
+ if (!(vectorSplit instanceof BucketVectorSearchSplit)) {
+ throw new UserException("Unexpected PK-vector split type: "
+ + vectorSplit.getClass().getName());
+ }
+ BucketVectorSearchSplit bucketSplit = (BucketVectorSearchSplit)
vectorSplit;
+ // Serialize with Paimon's own DataOutputSerializer: the bytes
must be
+ // exactly BucketVectorSearchSplit.serialize's DataOutputView form
+ // (magic "PKVSPLIT", version 1, big-endian), which
+ // paimon_bucket_vector_search_split_deserialize on BE decodes.
This
+ // repository's Paimon no longer ships a
BucketVectorSearchSplitSerializer.
+ byte[] payloadBytes;
+ try {
+ DataOutputSerializer output = new DataOutputSerializer(256);
+ bucketSplit.serialize(output);
+ payloadBytes = output.getCopyOfBuffer();
+ } catch (IOException e) {
+ throw new UserException("Failed to serialize PK-vector split:
" + e.getMessage(), e);
+ }
+ DataSplit dataSplit = bucketSplit.dataSplit();
+ PaimonSplit split = new PaimonSplit(dataSplit);
+ split.setVectorPayloadBytes(payloadBytes);
Review Comment:
[P1] Populate partition values on each vector split
These splits never receive `setPaimonPartitionValues`, so their connector
partition map remains null. During range construction, `setPaimonParams`
unconditionally calls `setPartitionValues`, which throws `Missing partition
values for Paimon partitioned table` whenever the table has partition keys.
Thus a nonempty ANN query on a partitioned PK-vector table fails even if it
only projects id/distance. The ordinary split path derives this map with
`PaimonUtil.getPartitionInfoMap`; populate the same metadata here and cover
partitioned vector range construction.
##########
be/test/format_v2/table/paimon_rust_table_reader_test.cpp:
##########
@@ -38,6 +40,7 @@
#include "core/field.h"
#include "exprs/vexpr_context.h"
#include "exprs/vliteral.h"
+#include "format/table/paimon_rust_predicate_converter.h"
Review Comment:
[P1] Include the predicate header from its existing v2 path
`format/table/paimon_rust_predicate_converter.h` does not exist in this
checkout; the header is
`be/src/format_v2/table/paimon_rust_predicate_converter.h` (also the path used
by the production reader). `be/test/CMakeLists.txt` recursively includes this
test in `doris_be_test`, so its new include prevents that target from
preprocessing. Change it to `format_v2/table/paimon_rust_predicate_converter.h`.
##########
be/src/format_v2/table/paimon_rust_table_reader.cpp:
##########
@@ -509,6 +626,313 @@ Status PaimonRustTableReader::_open_split_reader(const
TFileRangeDesc& range) {
return Status::OK();
}
+Status PaimonRustTableReader::_open_vector_split_reader(const TFileRangeDesc&
range) {
+ // Validate every __isset / value-range up front (mirrors
+ // LanceTableReader::_validate_external_search_request). The build path
below
+ // then uses the fields unchecked, matching lance's split of validate-once
/
+ // build-without-recheck.
+ RETURN_IF_ERROR(_validate_external_search_request());
+ const auto& request = _scan_params->external_search_request;
+ const auto& vector = request.search_query.vector_search;
+
+ // 0. Distance metric. It decides which score transform the reader has to
undo
+ // (see _convert_score_to_distance), so defaulting would silently return
wrong
+ // distances. __isset was checked by _validate_external_search_request; the
+ // value is rejected here if unsupported.
+ ScoreTransform transform;
+ RETURN_IF_ERROR(parse_score_transform(vector.metric, &transform));
+ _score_transform = transform;
+
+ // 1. The per-split payload is the serialized BucketVectorSearchSplit (raw
+ // thrift binary, NOT base64; magic "PKVSPLIT", v1). Grab it first so we
fail
+ // fast before any filesystem IO.
+ const auto& payload =
_current_range.table_format_params.paimon_params.vector_payload;
+ if (payload.payload_type != TPaimonVectorPayloadType::BUCKET_SPLIT_BYTES) {
+ return Status::InternalError("paimon-rust vector search unsupported
payload type: {}",
+ static_cast<int>(payload.payload_type));
+ }
+ const std::string& split_bytes = payload.bytes;
+ if (split_bytes.empty()) {
+ return Status::InternalError("paimon-rust vector search split payload
is empty");
+ }
+
+ // 2. Resolve and open (or reuse) the table (same as the normal read path);
+ // options carry storage credentials to the FileIO.
+ RETURN_IF_ERROR(_open_paimon_table(range));
+
+ // 3. Build and configure the vector-search builder. No read.batch-size
here,
+ // unlike the batch path (_open_split_reader): paimon-rust's PK vector
readers
+ // construct their DataFileReaders without with_batch_size, so a
+ // read.batch-size option — from the schema, t@options or these search
+ // options — is silently ignored on the vector path, which batches at the
+ // parquet-rust default. Known difference; the vector stages are
+ // single-column streamed into bounded Top-K heaps and the output is
+ // capped by top_k, so no action needed until paimon-rust wires it.
+ paimon_result_vector_search_builder vb_res =
+ paimon_table_new_vector_search_builder(_handles->table.get());
+ if (vb_res.error != nullptr) {
+ return Status::InternalError("paimon-rust new vector search builder
failed: {}",
+ consume_error(vb_res.error));
+ }
+ vector_search_builder_ptr vb(vb_res.builder);
+
+ // 3a. Target vector column.
+ if (paimon_error* err =
+ paimon_vector_search_builder_with_vector_column(vb.get(),
vector.column.c_str())) {
+ return Status::InternalError("paimon-rust set vector column failed:
{}",
+ consume_error(err));
+ }
+ // 3b. Query vector. The wire form is a TSearchVector carrying a FLOAT32,
+ // little-endian binary blob; decode it into the f32 array the C API wants.
+ // v1 only supports FLOAT32 (paimon-rust's index backends operate on f32);
any
+ // other element type was already rejected by validate. Dimension and
+ // byte-size were validated there too, so decode unchecked here.
+ const auto& search_vec = vector.query_vector;
+ const auto dimension = static_cast<size_t>(search_vec.dimension);
+ std::vector<float> query_vector(dimension);
+ const auto* bytes = search_vec.values.data();
+ for (size_t i = 0; i < dimension; ++i) {
+ query_vector[i] = std::bit_cast<float>(LittleEndian::Load32(
+ reinterpret_cast<const uint8_t*>(bytes + i *
sizeof(uint32_t))));
+ }
+ if (paimon_error* err = paimon_vector_search_builder_with_query_vector(
+ vb.get(), query_vector.data(), query_vector.size())) {
+ return Status::InternalError("paimon-rust set query vector failed:
{}", consume_error(err));
+ }
+ // 3c. Retrieval limit (user_limit + user_offset, folded on FE). A negative
+ // limit would wrap when cast to uintptr_t, so guard it.
+ if (vector.top_k < 0) {
+ return Status::InternalError("paimon-rust vector search limit is
negative: {}",
+ vector.top_k);
+ }
+ if (paimon_error* err = paimon_vector_search_builder_with_limit(
+ vb.get(), static_cast<uintptr_t>(vector.top_k))) {
+ return Status::InternalError("paimon-rust set limit failed: {}",
consume_error(err));
+ }
+ // 3d. Index/search options (refine_factor, ivf.nprobe, "<algo>.metric",
...).
+ if (request.__isset.paimon_options &&
request.paimon_options.__isset.options &&
+ !request.paimon_options.options.empty()) {
+ std::vector<paimon_option> c_search_options;
+ c_search_options.reserve(request.paimon_options.options.size());
+ for (const auto& kv : request.paimon_options.options) {
+ c_search_options.emplace_back(kv.first.c_str(), kv.second.c_str());
+ }
+ if (paimon_error* err = paimon_vector_search_builder_with_options(
+ vb.get(), c_search_options.data(),
c_search_options.size())) {
+ return Status::InternalError("paimon-rust set search options
failed: {}",
+ consume_error(err));
+ }
+ }
+ // 3e. Materialized user columns. The score field is always appended by
+ // paimon-rust itself and must NOT be listed here. Empty projection = only
the
+ // score column (index-only scan). Log the user columns: an empty list is
+ // legitimate, so it is logged as such rather than skipped.
+ auto read_columns = _build_read_columns();
+ std::vector<const char*> projection;
+ projection.reserve(read_columns.size() + 1);
+ for (const auto& col : read_columns) {
+ projection.push_back(col.c_str());
+ }
+ projection.push_back(nullptr);
+ LOG(INFO) << "paimon-rust vector search: projection=[" <<
join(read_columns, ", ") << "] ("
+ << read_columns.size() << " column(s), distance column '"
+ << kPaimonSearchDistanceColumn << "' produced by the reader)";
+ if (paimon_error* err =
+ paimon_vector_search_builder_with_projection(vb.get(),
projection.data())) {
+ return Status::InternalError("paimon-rust set vector projection
failed: {}",
+ consume_error(err));
+ }
+
+ // 3f. Optional residual scalar filter from the FE data-half push-down. The
+ // FE-side DV/merge-on-read interception is intentionally not done (see
+ // PushDownVectorTopNIntoPaimonScan): the Rust candidate search stage
applies
+ // the filter together with deletion vectors, and a table shape Rust cannot
+ // handle correctly is Rust's responsibility to reject.
+ if (!_conjuncts.empty()) {
+ std::vector<std::string> names;
+ std::vector<DataTypePtr> types;
+ names.reserve(_projected_columns.size());
+ types.reserve(_projected_columns.size());
+ for (const auto& col : _projected_columns) {
+ if (col.is_partition_key || col.name ==
kPaimonSearchDistanceColumn) {
+ continue;
+ }
+ names.push_back(col.name);
+ types.push_back(col.type);
+ }
+ PaimonRustPredicateConverter converter(names, types,
_handles->table.get());
+ paimon_predicate* predicate = converter.build(_conjuncts);
+ if (predicate != nullptr) {
+ // with_filter consumes the predicate on every path.
+ if (paimon_error* err =
paimon_vector_search_builder_with_filter(vb.get(), predicate)) {
+ return Status::InternalError("paimon-rust set vector filter
failed: {}",
+ consume_error(err));
+ }
+ LOG(INFO) << "paimon-rust vector search: applied residual filter";
+ }
+ }
+
+ // 4. Execute the search over the single FE-planned bucket split, via the
+ // eager Top-K chain (paimon-rust #822). Search, refine, local Top-K and
+ // materialization all happen in paimon_vector_read_read; the returned
arrow
+ // stream is best-first and carries the __paimon_search_score column. The
+ // intermediate handles are locals: the migration contract (see
+ // doris-vector-search-api-migration.md) guarantees the record batch reader
+ // is independent once paimon_vector_read_read returns, so they are
released
+ // when this function exits.
+ //
+ // split: the bytes only need to live until deserialize returns.
+ // scan: only needs to live until the plan is constructed.
+ // builder: only needs to live until scan and read are created.
+ // read/plan: only need to live until paimon_vector_read_read returns.
+ paimon_result_bucket_vector_search_split split_result =
+ paimon_bucket_vector_search_split_deserialize(
+ reinterpret_cast<const uint8_t*>(split_bytes.data()),
split_bytes.size());
+ if (split_result.error != nullptr) {
+ return Status::InternalError("paimon-rust deserialize vector split
failed: {}",
+ consume_error(split_result.error));
+ }
+ bucket_vector_search_split_ptr split(split_result.split);
+
+ paimon_result_vector_scan scan_result =
paimon_vector_search_builder_new_scan(vb.get());
+ if (scan_result.error != nullptr) {
+ return Status::InternalError("paimon-rust new vector scan failed: {}",
+ consume_error(scan_result.error));
+ }
+ vector_scan_ptr scan(scan_result.scan);
+
+ paimon_result_vector_read read_result =
paimon_vector_search_builder_new_read(vb.get());
+ if (read_result.error != nullptr) {
+ return Status::InternalError("paimon-rust new vector read failed: {}",
+ consume_error(read_result.error));
+ }
+ vector_read_ptr read(read_result.read);
+
+ // Must use plan_from_bucket_splits: it plans from the FE-assigned split,
+ // whereas paimon_vector_scan_plan / execute_read replan from the table and
+ // would ignore the split this scanner was given.
+ const paimon_bucket_vector_search_split* splits[] = {split.get()};
+ paimon_result_vector_plan plan_result =
+ paimon_vector_scan_plan_from_bucket_splits(scan.get(), splits, 1);
+ if (plan_result.error != nullptr) {
+ return Status::InternalError("paimon-rust build vector plan failed:
{}",
+ consume_error(plan_result.error));
+ }
+ vector_plan_ptr plan(plan_result.plan);
+
+ paimon_result_record_batch_reader rdr_res =
paimon_vector_read_read(read.get(), plan.get());
+ if (rdr_res.error != nullptr) {
+ return Status::InternalError("paimon-rust vector search read failed:
{}",
+ consume_error(rdr_res.error));
+ }
+ _handles->reader.reset(rdr_res.reader);
+ return Status::OK();
+}
+
+std::string PaimonRustTableReader::arrow_field_to_block_column(const
std::string& arrow_name) {
+ std::string name = to_lower(arrow_name);
+ if (name == kPaimonRustScoreField) {
+ return kPaimonSearchDistanceColumn;
+ }
+ return name;
+}
+
+Status PaimonRustTableReader::parse_score_transform(TVectorMetric::type metric,
+ ScoreTransform* transform)
{
+ switch (metric) {
+ case TVectorMetric::L2:
+ *transform = ScoreTransform::L2;
+ return Status::OK();
+ case TVectorMetric::DOT_PRODUCT:
+ *transform = ScoreTransform::InnerProduct;
+ return Status::OK();
+ default:
+ // Includes COSINE: paimon-rust does define a score for it
+ // (score = 1 - distance), but v1 does not push cosine down, so
reaching here
+ // means FE and BE disagree. Fail instead of inventing a transform.
+ return Status::InternalError(
+ "paimon-rust vector search unsupported distance metric: {} "
+ "(expected L2 or DOT_PRODUCT)",
+ static_cast<int>(metric));
+ }
+}
+
+void PaimonRustTableReader::_convert_score_to_distance(Block* block, uint32_t
block_idx,
+ size_t row_offset,
size_t num_rows,
+ ScoreTransform
transform) {
+ // Undo what paimon-rust's convert_distance_to_score did, so the column
holds
+ // what the Doris distance function is defined to return.
+ switch (transform) {
+ case ScoreTransform::InnerProduct:
+ // score IS the inner product. Nothing to undo, and no clamping to do
+ // either: the value is unconstrained by definition (it may be
negative).
+ return;
+ case ScoreTransform::L2:
+ break;
+ }
+
+ auto& column_with_type = block->get_by_position(block_idx);
+ auto column = IColumn::mutate(std::move(column_with_type.column));
+ PaddedPODArray<float>* data = nullptr;
+ const NullMap* null_map = nullptr;
+ if (auto* nullable = check_and_get_column<ColumnNullable>(column.get())) {
+ data =
&assert_cast<ColumnFloat32&>(nullable->get_nested_column()).get_data();
+ null_map = &nullable->get_null_map_data();
+ } else {
+ data = &assert_cast<ColumnFloat32&>(*column).get_data();
+ }
+
+ // score = 1/(1 + d) where d is the SQUARED L2 distance, and
+ // l2_distance_approximate returns the true Euclidean one, so invert both
steps:
+ // d_euclidean = sqrt(1/score - 1).
+ const size_t end = std::min(row_offset + num_rows, data->size());
+ for (size_t i = row_offset; i < end; ++i) {
+ if (null_map != nullptr && (*null_map)[i] != 0) {
+ continue;
+ }
+ const float score = (*data)[i];
+ // score is in (0, 1] whenever d is finite and non-negative, and
within that
+ // range the radicand is provably non-negative: a real value above 1
cannot
+ // round down to below 1.0F in f32, so 1/score >= 1. The useful range
+ // therefore never reaches either guard below -- they exist because the
+ // distances come straight out of the closed-source lumina kernel with
no
+ // clamp, and computing the inverse on an illegal score would produce a
+ // non-finite distance (score <= 0 -> 1/score is inf; score > 1 ->
negative
+ // radicand -> NaN), which makes the coordinator's Top-N comparisons
+ // ill-defined. Report a distinct negative sentinel per case instead:
both
+ // are unreachable for a genuine Euclidean distance, so they surface as
+ // visibly bogus rows that also say WHICH way the index is broken.
+ //
+ // A score slightly above 1 is NOT corruption: the kernel computes
squared
+ // L2 as ||a||^2 + ||b||^2 - 2<a,b>, which can yield a small negative
+ // residual (catastrophic cancellation) when a ~= b -- a near-exact
hit.
+ // Clamp those to distance 0; only scores past kScoreAboveOneTolerance
are
+ // treated as genuinely corrupt.
+ if (UNLIKELY(score <= 0.0F)) {
+ LOG(WARNING) << "paimon-rust L2 score_to_distance: corrupt score "
<< score << " (row "
+ << i << ", expect score in (0, 1]), "
+ << "sentinel " << kCorruptScoreNotPositive;
+ (*data)[i] = kCorruptScoreNotPositive;
Review Comment:
[P1] Preserve the ordering of zero L2 scores
On a table with `vector-index.search-mode=full` and active files not yet
indexed, a zero score can come from valid finite vectors: the pinned Rust exact
fallback computes squared L2 in f32, so `[1e20f,0,...]` against `[0,0,...]`
overflows to `+inf` and its `1/(1+d)` score is zero. With that row alone in one
bucket and `[1f,0,...]` in another, `ORDER BY l2_distance_approximate(vec,q)
LIMIT 1` receives both bucket candidates. This branch changes the far row's
distance to `-2`, making the coordinator choose it ahead of the distance-1 row;
the ordinary function returns `+inf` and 1 respectively. Preserve `+inf` for
the zero-score boundary, or fail explicitly on invalid scores, rather than
publishing negative distances that reverse the final TopN.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoPaimonScan.java:
##########
@@ -0,0 +1,513 @@
+// 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.rules.rewrite;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.datasource.credentials.CredentialUtils;
+import org.apache.doris.datasource.credentials.VendedCredentialsFactory;
+import org.apache.doris.datasource.paimon.PaimonExternalCatalog;
+import org.apache.doris.datasource.paimon.PaimonExternalTable;
+import org.apache.doris.datasource.paimon.PaimonUtil;
+import org.apache.doris.datasource.paimon.source.PaimonVectorSearch;
+import org.apache.doris.datasource.property.storage.StorageProperties;
+import org.apache.doris.nereids.rules.Rule;
+import org.apache.doris.nereids.rules.RuleType;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.ComparisonPredicate;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.InPredicate;
+import org.apache.doris.nereids.trees.expressions.IsNull;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Not;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.InnerProductApproximate;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.L2DistanceApproximate;
+import org.apache.doris.nereids.trees.expressions.literal.ArrayLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.plans.AnnTopNInfo;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
+import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.thrift.TVectorMetric;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.MultisetType;
+import org.apache.paimon.types.RowType;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Push a primary-key vector (ANN) top-N down into the Paimon scan
(index-only).
+ *
+ * <p>Recognizes {@code TopN[order by dis] -> Project[dis := dist_fn(vec, q)]
-> [Filter] -> PaimonScan}
+ * where {@code dist_fn} is {@code l2_distance_approximate} (ASC) or
+ * {@code inner_product_approximate} (DESC) and the Paimon table has a
PK-vector
+ * index on {@code vec} with a matching distance metric.
+ *
+ * <p>The rewrite mirrors the internal-table ANN path (see
+ * {@code PushDownVectorTopNIntoOlapScan}): the scan is told to produce a
+ * reader-generated {@code __paimon_search_score_to_dis} column via the
PK-vector index, and
+ * every occurrence of {@code dist_fn(vec, q)} is replaced by that column's
slot. The
+ * order key slot keeps its identity, so the surrounding TopN merges/limits
unchanged.
+ * The distance always comes from the index, never from the raw vector.
+ *
+ * <p>No arithmetic is emitted here even though paimon-rust reports a score
rather than
+ * a distance: the BE reader undoes the metric-dependent score transform in
place while
+ * filling the block, exactly as the internal table sqrt's faiss's squared
distance inside
+ * the index layer. So the column arrives already holding what {@code dist_fn}
would
+ * return, and the plan stays a bare slot reference -- which also keeps {@code
dis}
+ * non-nullable (an expression over the score would not be, since {@code
Divide} and
+ * {@code Sqrt} are always nullable) and makes predicates like
+ * {@code dist_fn(vec, q) < 10} correct without any direction flip.
+ *
+ * <p>Being index-only is just an optimization here, not a precondition: if
nothing
+ * else references the raw vector column it is left unreferenced and dropped
by column
+ * pruning (pure index-only read); if the query still needs it ({@code SELECT
vec} or a
+ * predicate on {@code vec}), it stays a normal projected column and the BE
vector
+ * reader materializes it alongside the score. Either way ANN is applied --
the rewrite
+ * never falls back merely because the vector is read.
+ */
+public class PushDownVectorTopNIntoPaimonScan implements RewriteRuleFactory {
+
+ /** Doris column types the BE paimon-rust predicate converter can push; see
+ * PaimonRustPredicateConverter._is_supported_slot_type on the BE side. */
+ private static final Set<Type> VECTOR_FILTER_SUPPORTED_COLUMN_TYPES =
ImmutableSet.of(
+ Type.BOOLEAN, Type.TINYINT, Type.SMALLINT, Type.INT, Type.BIGINT,
+ Type.STRING, Type.DATE, Type.DATEV2);
+
+ @Override
+ public List<Rule> buildRules() {
+ return ImmutableList.of(
+ logicalTopN(logicalProject(logicalFileScan())).when(t ->
t.getOrderKeys().size() == 1).then(topN -> {
+ LogicalProject<LogicalFileScan> project = topN.child();
+ LogicalFileScan scan = project.child();
+ return pushDown(topN, project, scan, Optional.empty());
+ }).toRule(RuleType.PUSH_DOWN_VECTOR_TOPN_INTO_PAIMON_SCAN),
+ logicalTopN(logicalProject(logicalFilter(logicalFileScan())))
+ .when(t -> t.getOrderKeys().size() == 1).then(topN -> {
+ LogicalProject<LogicalFilter<LogicalFileScan>>
project = topN.child();
+ LogicalFilter<LogicalFileScan> filter =
project.child();
+ LogicalFileScan scan = filter.child();
+ return pushDown(topN, project, scan,
Optional.of(filter));
+
}).toRule(RuleType.PUSH_DOWN_VECTOR_TOPN_INTO_PAIMON_SCAN)
+ );
+ }
+
+ private Plan pushDown(
+ LogicalTopN<?> topN,
+ LogicalProject<?> project,
+ LogicalFileScan scan,
+ Optional<LogicalFilter<?>> optionalFilter) {
+ // Only Paimon external tables carry PK-vector indexes.
+ if (!(scan.getTable() instanceof PaimonExternalTable)) {
+ return null;
+ }
+
+ // The index-only search path is executed exclusively by the BE
paimon-rust reader,
+ // which only FileScannerV2 can select (the V1 FileScanner explicitly
rejects
+ // PAIMON_RUST). If the rust reader or the V2 scanner is disabled, the
split would
+ // fall through to the JNI reader, which ignores the vector payload
and would
+ // return a non-ANN scan with an empty distance column. Fall back to a
normal
+ // scan then.
+ ConnectContext ctx = ConnectContext.get();
+ if (ctx == null || ctx.getSessionVariable() == null
+ || !ctx.getSessionVariable().isEnablePaimonRustReader()
+ || !ctx.getSessionVariable().enableFileScannerV2) {
+ return null;
+ }
+
+ // An incremental scan (t@incr / BETWEEN) must not be rewritten into a
+ // PK-vector search: the VectorScan builder has no incremental mode,
so a
+ // vector payload would silently return snapshot rows for a query that
asked
+ // for changes. Keeping the query un-rewritten runs it as the ordinary
+ // incremental scan the user requested, with the distance computed in
Doris.
+ // (The scan node's vector gate keeps !isIncremental as the
authoritative
+ // backstop for any path that produces a vector split regardless.)
+ //
+ // The same holds for a pinned file-creation-time read state: the
ordinary
+ // path enforces `scan.file-creation-time-millis` by dropping manifest
+ // entries older than the bound, but the vector search plans its own
splits
+ // and never consults that bound, so old files' nearest neighbors
could leak
+ // into the result. Snapshot/tag/version selectors are fine -- they are
+ // resolved into the table handle before the vector search -- so only
the
+ // file-level bound forces the decline.
+ if (scan.getScanParams().isPresent()) {
+ TableScanParams scanParams = scan.getScanParams().get();
+ if (scanParams.incrementalRead()) {
+ return null;
+ }
+ Map<String, String> rawParams = scanParams.getMapParams();
+ if
(rawParams.containsKey(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())) {
+ return null;
+ }
+ }
+
+ // A bounded vector top-N is only correct when every filter conjunct is
+ // applied inside the search, before the limit is exhausted. The BE
+ // paimon-rust predicate converter skips conjuncts it cannot represent
(and
+ // the synthetic distance column is not in its field registry at all),
and
+ // the engine re-applies those residuals on the returned block --
after the
+ // top-K rows were already selected. A skipped residual can therefore
drop
+ // rows out of a bounded result: with candidate distances 0, 2 and 3, a
+ // `WHERE dis > 1 ORDER BY dis LIMIT 1` would fetch only the
distance-0 row,
+ // filter it out and return nothing instead of the distance-2 row. So
the
+ // rewrite only fires when each conjunct is provably of a form the
converter
+ // pushes: a simple comparison / IN / null-check on a table column
whose
+ // Doris type is in the converter's supported set (see
+ // PaimonRustPredicateConverter._is_supported_slot_type on BE).
Anything
+ // else -- an expression predicate, a predicate on the distance
itself, an
+ // unsupported column type -- declines the rewrite and the query runs
as an
+ // ordinary scan with the distance computed in Doris, which is always
+ // correct.
+ if (optionalFilter.isPresent() &&
!conjunctsRideIntoVectorSearch(optionalFilter.get())) {
+ return null;
+ }
+
+ // The order key must be a SlotReference produced by a Project alias.
+ Expression orderKey = topN.getOrderKeys().get(0).getExpr();
+ if (!(orderKey instanceof SlotReference)) {
+ return null;
+ }
+ SlotReference keySlot = (SlotReference) orderKey;
+ Alias orderKeyAlias = null;
+ Expression orderKeyExpr = null;
+ for (NamedExpression projection : project.getProjects()) {
+ if (projection.toSlot().equals(keySlot) && projection instanceof
Alias) {
+ orderKeyAlias = (Alias) projection;
+ orderKeyExpr = orderKeyAlias.child();
+ break;
+ }
+ }
+ if (orderKeyExpr == null) {
+ return null;
+ }
+
+ // l2 must sort ascending (smaller distance = closer); inner_product
descending.
+ boolean isAsc = topN.getOrderKeys().get(0).isAsc();
+ boolean l2Dist = orderKeyExpr instanceof L2DistanceApproximate;
+ boolean innerProduct = orderKeyExpr instanceof InnerProductApproximate;
+ if (!(l2Dist && isAsc) && !(innerProduct && !isAsc)) {
+ return null;
+ }
+
+ // The indexed column must be a direct slot reference. A Cast chain
over the
+ // column is NOT stripped: a legal cast can change vector values (for
example
+ // an ARRAY<FLOAT> -> ARRAY<INT> -> ARRAY<FLOAT> round trip loses
precision),
+ // and searching the original vectors while the query means the casted
ones
+ // changes both the distance and the row order. Doris's implicit
coercions do
+ // not produce a cast here for a matching ARRAY<FLOAT> column, so
anything
+ // wrapped in a Cast simply declines the rewrite.
+ Expression left;
+ Expression right;
+ if (l2Dist) {
+ left = ((L2DistanceApproximate) orderKeyExpr).left();
+ right = ((L2DistanceApproximate) orderKeyExpr).right();
+ } else {
+ left = ((InnerProductApproximate) orderKeyExpr).left();
+ right = ((InnerProductApproximate) orderKeyExpr).right();
+ }
+ if (!(left instanceof SlotReference && right.isConstant() && right
instanceof ArrayLiteral)) {
+ return null;
+ }
+ SlotReference vectorSlot = (SlotReference) left;
+ if (!vectorSlot.getOriginalColumn().isPresent() ||
!vectorSlot.getOriginalTable().isPresent()) {
+ return null;
+ }
+ String annColumnName = vectorSlot.getOriginalColumn().get().getName();
+
+ // Validate the PK-vector index and metric on the Paimon table.
+ PaimonExternalTable paimonTable = (PaimonExternalTable)
scan.getTable();
+ CoreOptions coreOptions;
+ try {
+ Table basePaimonTable =
paimonTable.getPaimonTable(Optional.empty());
+ coreOptions = CoreOptions.fromMap(basePaimonTable.options());
+ } catch (Throwable t) {
+ return null;
+ }
+ if (!coreOptions.primaryKeyVectorIndexEnabled()) {
+ return null;
+ }
+ boolean columnIndexed =
coreOptions.primaryKeyVectorIndexColumns().stream()
+ .anyMatch(c -> c.equalsIgnoreCase(annColumnName));
+ if (!columnIndexed) {
+ return null;
+ }
+ // v1 supports only l2 / inner_product; the function must match the
index metric.
+ String metric =
coreOptions.primaryKeyVectorDistanceMetric(annColumnName);
+ String expectedMetric = l2Dist ? "l2" : "inner_product";
+ if (!expectedMetric.equals(metric)) {
+ return null;
+ }
+
+ // Pre-flight the projection-dependent bounds the vector split would
hit at
+ // the scan node: a PK-vector split has no JNI fallback (it would
return an
+ // empty distance column), so a query whose projected columns would set
+ // projectedVariant -- or an ORC table with TIMESTAMP_LTZ in its
schema, the
+ // orc-ltz-shape bound -- must not be rewritten at all. Without the
rewrite
+ // those queries run through the ordinary paths, where they are
supported.
+ try {
+ if (projectedColumnsContainVariant(project, optionalFilter)
+ || orcSchemaContainsTimestampLtz(paimonTable)) {
+ return null;
+ }
+ } catch (Throwable t) {
+ return null;
+ }
+
+ // The thrift-level enum the AnnTopNInfo will carry. expectedMetric is
kept as a
+ // string for the comparison above (coreOptions returns a string), but
the
+ // AnnTopNInfo / wire contract uses TVectorMetric, so map once here.
DOT_PRODUCT
+ // is the thrift enum that paimon-rust's "inner_product" score
transform maps to
+ // (see the BE reader's parse_score_transform).
+ TVectorMetric expectedMetricEnum = l2Dist ? TVectorMetric.L2 :
TVectorMetric.DOT_PRODUCT;
+
+ // Pre-flight the rust eligibility the scan node will gate on. The
rewrite is
+ // one-way: a PK-vector split has no JNI fallback (the JNI reader
ignores the
+ // vector payload and would return a non-ANN scan with an empty
distance
+ // column), so the scan node can only fail loudly — a table whose gate
would
+ // fail (credential provider shape, REST-token IO, query-auth,
merge-option
+ // matrix, deletion-vector merge-on-read, scan-mode, storage scheme or
HDFS
+ // credentials, no rust-capable backend) must not be rewritten at all.
Without
+ // the rewrite the query still runs, computing the distance in Doris.
The
+ // projection-dependent bounds (ORC LTZ, projected variant) need the
final scan
+ // tuple and stay scan-node-side, and the scan node re-evaluates on the
+ // relation-option-processed table, so it stays authoritative for
t@options
+ // overrides this raw-table pre-flight cannot see.
+ try {
+ Table baseTable = paimonTable.getPaimonTable(Optional.empty());
+ PaimonExternalCatalog catalog = (PaimonExternalCatalog)
paimonTable.getCatalog();
+ Map<StorageProperties.Type, StorageProperties>
storagePropertiesMap =
+
VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials(
+
catalog.getCatalogProperty().getMetastoreProperties(),
+
catalog.getCatalogProperty().getStoragePropertiesMap(),
+ baseTable);
+ Map<String, String> backendStorageProperties =
+
CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap);
+ String tableLocation = baseTable instanceof FileStoreTable
+ ? ((FileStoreTable) baseTable).location().toString()
+ : baseTable.options().get("path");
+ if (!PaimonVectorSearch.vectorScanEligible(baseTable,
tableLocation,
+ backendStorageProperties,
+ Env.getCurrentSystemInfo().getAllClusterBackends(true))) {
+ return null;
+ }
+ } catch (Throwable t) {
+ return null;
+ }
+ // NOTE: the FE-side data-predicate interception is intentionally not
done. The
+ // data predicate is handed in full to the BE via the vector search
builder's
+ // residual filter, where the Rust candidate search stage applies it
together
+ // with deletion vectors. If some table shape cannot be handled
correctly, it is
+ // Rust's responsibility to reject it or return correct data, not the
planner's.
+
+ // paimon-rust's vector index operates on f32, so carry the query as
floats
+ // rather than List<Double>: it avoids a double→float narrowing on the
wire
+ // and lets PaimonScanNode encode straight into a FLOAT32
TSearchVector.
+ List<Literal> vectorLiterals = ((ArrayLiteral) right).getValue();
+ float[] annQueryVector = new float[vectorLiterals.size()];
+ for (int i = 0; i < vectorLiterals.size(); i++) {
+ annQueryVector[i] = (float) vectorLiterals.get(i).getDouble();
Review Comment:
[P2] Reject NULL coordinates before encoding the query vector
An `ArrayLiteral` can contain a typed `NullLiteral`, whose `getDouble()`
returns zero. Consequently a correctly dimensioned query vector such as
`[CAST(NULL AS FLOAT),0.0,...]` becomes a search against a zero coordinate, and
the rewrite removes the original distance function. On a nonempty table the
ordinary `FunctionArrayDistance::_validate_no_nulls` throws `Second argument
... cannot have null`; enabling ANN instead returns rows for a different input.
Decline the rewrite for NULL elements or raise the equivalent error before
replacing the expression. Both supported metrics have this issue.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonRustEligibility.java:
##########
@@ -0,0 +1,298 @@
+// 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.paimon.source;
+
+import org.apache.doris.datasource.paimon.PaimonScanParams;
+import org.apache.doris.system.Backend;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.rest.RESTTokenFileIO;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * The table-, storage- and backend-level gates of the paimon-rust reader,
+ * computed once from the table handle and shared by every call site that must
+ * agree on them: {@code PaimonScanNode}'s per-split gate (both the batch
+ * reader and the PK-vector search) and the {@code
PushDownVectorTopNIntoPaimonScan}
+ * rewrite's pre-flight via {@code PaimonVectorSearch.vectorScanEligible}. The
+ * rewrite is one-way (a rewritten PK-vector split has no JNI fallback), so
+ * both sites must evaluate the same conditions; this class is that single
+ * definition.
+ *
+ * <p>Covers only what is a property of the table handle, the storage shape or
+ * the cluster's backends. Everything that depends on the individual split
+ * (native DataSplit shape, unmaterialized deletion vectors, external-path
+ * files, the PaimonRustReaderCapabilities whitelist) or on the final scan
+ * tuple (ORC TIMESTAMP_LTZ, projected VARIANT) stays with the scan node.
+ */
+final class PaimonRustEligibility {
+
+ // BE opens the table via paimon_table_from_schema_json, which needs the
resolved
+ // TableSchema that only FileStoreTable exposes via schema(). A table that
is not a
+ // FileStoreTable (e.g. a sys table backed by DataSplit) cannot ship a
schema JSON,
+ // so it falls back to JNI rather than sending an incomplete PAIMON_RUST
request
+ // that BE would reject.
+ final boolean isFileStoreTable;
+
+ // Both sides of a FallbackReadFileStoreTable wrap their splits, and the
pinned
+ // rust decoder rejects a FallbackDataSplit outright ("trailing bytes after
+ // DataSplit" — it requires full-buffer consumption); the wrapper is gated
as a
+ // whole (any split from it routes to JNI) until the rust ABI represents
both
+ // sides. The per-split FallbackSplit shape is judged separately in
PaimonScanNode.
+ final boolean fallbackReadTable;
+
+ // A renewable REST token reached the shipped properties as a plain value;
only
+ // the table's FileIO type reveals it expires: doInitialize snapshots
+ // RESTTokenFileIO.validToken().token() into the backend storage
properties,
+ // discarding expireAtMillis and the REST refresh context, so the shipped
+ // credentials look static — but the pinned rust table reuses one option
map with
+ // no refresh callback, while the JNI RESTTokenFileIO checks expiry before
each
+ // file operation and obtains a replacement token. A queued or long scan
that
+ // crosses the token TTL would start on rust and later fail authentication.
+ // Null-safe: a table handle whose FileIO is not resolved stays
rust-eligible,
+ // mirroring the CoreOptions null-safety below.
+ final boolean restTokenTable;
+
+ // query-auth.enabled tables stay on JNI: when catalog authorization
succeeds with
+ // no row filter or column mask, Paimon still leaves an ordinary DataSplit
+ // (restricted results use QueryAuthSplit and are already handled by the
+ // nativeSplit gate), so this table shape passes the compound gate — but
the
+ // shipped schema keeps query-auth.enabled=true and the pinned rust
ReadBuilder
+ // rejects every such table (its CoreOptions::ensure_read_authorized fails
closed
+ // because the client cannot enforce the row filter / column masking),
turning a
+ // valid authorized scan into a BE-open failure. Until the authorization
result
+ // can be transported and enforced by the rust ABI, these tables route to
JNI.
+ final boolean queryAuthTable;
+
+ // Partial-update / aggregation tables with deletion vectors: the pinned
rust
+ // read_pk rejects merge-engine=partial-update/aggregation with
+ // deletion-vectors.merge-on-read=true outright, so that table option
routes the
+ // whole table to JNI. merge-on-read has no Java accessor in the pinned
paimon, so
+ // it is read raw from the schema options map — the one the BE rust reader
+ // deserializes from the shipped schema JSON, so it cannot diverge from
what BE
+ // sees — with the rust parsing semantics (any case-insensitive "true" is
on,
+ // default false). For the remaining PU/AGG DV tables, per-split
materialization
+ // is judged in PaimonScanNode (splitDvNotMaterialized); Deduplicate stays
+ // rust-eligible (its read_pk routes uncompacted splits to the KV reader,
which
+ // applies the attached per-file DVs).
+ final boolean puAggDeletionVectors;
+ final boolean dvMergeOnRead;
+
+ // deduplicate.ignore-delete=true tables stay on JNI: Java's
+ // DeduplicateMergeFunction skips retract records when the option is set —
+ // including old, uncompacted files that still contain them — but the
pinned rust
+ // read_pk does not pass table options into its deduplicate merge: it
picks the
+ // latest row and omits the key when that row is DELETE/UPDATE_BEFORE. An
+ // uncompacted insert followed by a delete therefore returns the insert
through
+ // JNI but silently disappears through rust. Gate the option until the rust
+ // merge implements it.
+ final boolean deduplicateIgnoreDelete;
+
+ // Non-DV merge options the pinned rust read rejects: Java supports
+ // partial-update.remove-record-on-delete /
aggregation.remove-record-on-delete
+ // and the wider per-field retract matrix, but the rust
PartialUpdateConfig /
+ // AggregationConfig validations return Unsupported for them — and the
DV-derived
+ // gates above only cover deletion-vector tables, so an ordinary non-DV
DataSplit
+ // with one of these options would pass the compound gate and fail during
the
+ // rust merge construction. Mirrors the exact rust key matrix (presence,
not
+ // values) against the same schema options map BE deserializes.
+ final boolean rustUnsupportedMergeOption;
+
+ // Branch tables can retain persisted modes without a selector: the shipped
+ // schema options carry scan.mode, and the pinned Rust builders only
accept the
+ // default.
+ final boolean scanModeNotDefault;
+
+ // The credential provider shape is evaluated by providerModeTranslatable
below.
+ final boolean providerModeTranslatable;
+
+ // Scheme capability gate: the pinned paimon-rust storage dispatcher
(io/storage.rs)
+ // selects the FileIO parser from the table location's URI scheme, and
+ // libpaimon_c.a compiles in separate COS, OBS, GCS and Azdls parsers
besides the
+ // OSS and S3 ones. Doris normalizes every object store's credentials into
the
+ // AWS_* / use_path_style aliases, which the BE rust bridge translates
only into
+ // the fs.oss.* and s3.* key families — a cosn:// / obs:// / gs:// /
abfs://
+ // warehouse would reach its scheme's parser without the key family it
reads and
+ // fail the open instead of using JNI. Only the schemes whose property
translation
+ // is implemented and open-tested (s3 / s3a / oss) plus the
credential-free hdfs
+ // and local-filesystem parsers stay rust-eligible. A null location also
routes to
+ // JNI: the rust path needs the paimon_table that only a real location can
provide.
+ final boolean schemeCapabilityVerified;
+
+ // An hdfs:// location is scheme-verified only together with the
credential-free
+ // backend shape: the backend storage properties that ship to BE also
carry an
+ // HDFS catalog's authentication (kerberos principal / keytab, proxy user,
HA
+ // nameservice config), none of which the pinned rust HDFS parser reads —
the
+ // scan would open as the BE process's ambient identity instead of the
catalog's
+ // configured one and fail the access JNI honors. See
isRustVerifiedHdfsBackend.
+ final boolean hdfsBackendVerified;
+
+ // The BE capability negotiation requires every candidate backend to report
+ // supports_paimon_rust_reader.
+ final boolean allBackendsRustCapable;
+
+ private PaimonRustEligibility(boolean isFileStoreTable, boolean
fallbackReadTable,
+ boolean restTokenTable, boolean queryAuthTable, boolean
puAggDeletionVectors,
+ boolean dvMergeOnRead, boolean deduplicateIgnoreDelete, boolean
rustUnsupportedMergeOption,
+ boolean scanModeNotDefault, boolean providerModeTranslatable,
boolean schemeCapabilityVerified,
+ boolean hdfsBackendVerified, boolean allBackendsRustCapable) {
+ this.isFileStoreTable = isFileStoreTable;
+ this.fallbackReadTable = fallbackReadTable;
+ this.restTokenTable = restTokenTable;
+ this.queryAuthTable = queryAuthTable;
+ this.puAggDeletionVectors = puAggDeletionVectors;
+ this.dvMergeOnRead = dvMergeOnRead;
+ this.deduplicateIgnoreDelete = deduplicateIgnoreDelete;
+ this.rustUnsupportedMergeOption = rustUnsupportedMergeOption;
+ this.scanModeNotDefault = scanModeNotDefault;
+ this.providerModeTranslatable = providerModeTranslatable;
+ this.schemeCapabilityVerified = schemeCapabilityVerified;
+ this.hdfsBackendVerified = hdfsBackendVerified;
+ this.allBackendsRustCapable = allBackendsRustCapable;
+ }
+
+ /**
+ * Whether the credential provider shape in the backend storage properties
can be
+ * translated to the pinned paimon-rust reader without changing the
identity the
+ * catalog configured. The rust S3 bridge maps static credentials,
anonymous access
+ * (AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS -> s3.anonymous) and
assume-role, but
+ * the remaining provider modes are ambient JVM provider chains (ENV,
+ * SYSTEM_PROPERTIES, WEB_IDENTITY, CONTAINER, INSTANCE_PROFILE) with no
paimon-rust
+ * equivalent — rust would silently sign with whatever its own ambient
chain
+ * resolves to. Lives here as one of the shared eligibility gates; also
called
+ * directly for the JNI fallback-reason logging.
+ */
+ static boolean providerModeTranslatable(String location,
+ Map<String, String> backendStorageProperties) {
+ String providerType = backendStorageProperties == null
+ ? null :
backendStorageProperties.get("AWS_CREDENTIALS_PROVIDER_TYPE");
+ String mode = providerType == null ? "DEFAULT" :
providerType.trim().toUpperCase(Locale.ROOT);
+ if (mode.isEmpty()) {
+ mode = "DEFAULT";
+ }
+ if (location != null && (location.startsWith("s3://") ||
location.startsWith("s3a://"))) {
+ // Java DEFAULT may resolve JVM properties or anonymous
credentials;
+ // Rust's ambient chain is different. Only explicit keys or
explicit
+ // anonymous access can cross this boundary without changing
identity.
+ String accessKey = backendStorageProperties == null
+ ? null : backendStorageProperties.get("AWS_ACCESS_KEY");
+ String secretKey = backendStorageProperties == null
+ ? null : backendStorageProperties.get("AWS_SECRET_KEY");
+ boolean staticKeys = accessKey != null &&
!accessKey.trim().isEmpty()
+ && secretKey != null && !secretKey.trim().isEmpty();
+ // Hadoop's Simple provider gives static keys precedence over
anonymous,
+ // role and token settings. Rust interprets those settings
differently.
+ boolean conflictingStaticSettings = staticKeys &&
(mode.equals("ANONYMOUS")
+ ||
StringUtils.isNotEmpty(backendStorageProperties.get("AWS_ROLE_ARN"))
+ ||
StringUtils.isNotEmpty(backendStorageProperties.get("AWS_TOKEN")));
+ return !conflictingStaticSettings
+ && (mode.equals("ANONYMOUS") || (mode.equals("DEFAULT") &&
staticKeys));
+ }
+ if (providerType != null) {
+ // OSS has no anonymous FileIO mode in the pinned Rust dependency.
+ return mode.equals("DEFAULT")
+ || (mode.equals("ANONYMOUS") && (location == null
+ || !location.startsWith("oss://")));
+ }
+ return true;
+ }
+
+ static PaimonRustEligibility of(Table table, String tableLocation,
+ Map<String, String> backendStorageProperties, Collection<Backend>
backends) {
+ boolean isFileStoreTable = table instanceof FileStoreTable;
+ boolean fallbackReadTable = table instanceof
FallbackReadFileStoreTable;
+ boolean restTokenTable = false;
+ boolean queryAuthTable = false;
+ boolean puAggDeletionVectors = false;
+ boolean dvMergeOnRead = false;
+ boolean deduplicateIgnoreDelete = false;
+ boolean rustUnsupportedMergeOption = false;
+ if (isFileStoreTable) {
+ FileStoreTable fileStoreTable = (FileStoreTable) table;
+ restTokenTable = fileStoreTable.fileIO() instanceof
RESTTokenFileIO;
+ CoreOptions resolvedCoreOptions = fileStoreTable.coreOptions();
+ // Null-safe: a table handle whose CoreOptions is not resolved
(e.g. some
+ // wrapper shapes) stays rust-eligible rather than failing here —
the rust
+ // open itself rejects such a table if the option is really set.
+ if (resolvedCoreOptions != null) {
+ queryAuthTable = resolvedCoreOptions.queryAuthEnabled();
+ CoreOptions.MergeEngine mergeEngine =
resolvedCoreOptions.mergeEngine();
+ if (resolvedCoreOptions.deletionVectorsEnabled()
+ && (mergeEngine ==
CoreOptions.MergeEngine.PARTIAL_UPDATE
+ || mergeEngine ==
CoreOptions.MergeEngine.AGGREGATE)) {
+ puAggDeletionVectors = true;
+ TableSchema dvSchema = fileStoreTable.schema();
+ Map<String, String> dvOptions = dvSchema == null ? null :
dvSchema.options();
+ String mergeOnRead = dvOptions == null
+ ? null :
dvOptions.get(PaimonScanNode.DELETION_VECTORS_MERGE_ON_READ);
+ dvMergeOnRead = "true".equalsIgnoreCase(mergeOnRead);
+ }
+ if (mergeEngine == CoreOptions.MergeEngine.DEDUPLICATE
+ && resolvedCoreOptions.ignoreDelete()) {
+ deduplicateIgnoreDelete = true;
+ }
+ if (mergeEngine == CoreOptions.MergeEngine.PARTIAL_UPDATE
+ || mergeEngine == CoreOptions.MergeEngine.AGGREGATE) {
+ TableSchema mergeSchema = fileStoreTable.schema();
+ Map<String, String> mergeOptions =
+ mergeSchema == null ? null : mergeSchema.options();
+ rustUnsupportedMergeOption = mergeOptions != null
+ &&
PaimonScanNode.hasRustUnsupportedMergeOption(mergeOptions, mergeEngine);
+ }
+ }
+ }
+ // Null-safe on a null schema, mirroring the CoreOptions null-safety
above:
+ // a fallback-shaped table can carry a null schema.
+ boolean scanModeNotDefault = false;
+ if (isFileStoreTable && ((FileStoreTable) table).schema() != null) {
+ TableSchema schema = ((FileStoreTable) table).schema();
+ String scanMode =
PaimonScanParams.withoutTimeTravelSelectors(schema)
+ .options().get(CoreOptions.SCAN_MODE.key());
+ scanModeNotDefault = scanMode != null &&
!"default".equalsIgnoreCase(scanMode);
+ }
+ boolean schemeCapabilityVerified =
PaimonScanNode.isRustVerifiedLocationScheme(tableLocation);
+ boolean hdfsBackendVerified =
!PaimonScanNode.isHdfsLocationScheme(tableLocation)
+ ||
PaimonScanNode.isRustVerifiedHdfsBackend(backendStorageProperties);
+ boolean providerModeTranslatable = providerModeTranslatable(
+ tableLocation, backendStorageProperties);
+ boolean allBackendsRustCapable = backends != null &&
!backends.isEmpty()
+ &&
backends.stream().allMatch(Backend::isPaimonRustReaderSupported);
Review Comment:
[P2] Negotiate vector support separately from the batch Rust reader
This heartbeat bit is already true on BEs from the PR base, but those BEs
only implement ordinary Rust splits. An upgraded FE therefore passes this
preflight with an older Rust-capable BE, rewrites the query, and sends
`paimon_split=""` plus the new vector payload. The older `_validate_rust_split`
rejects that range as `missing paimon_split`, so enabling the optimization
breaks a query that could still run normally during a mixed-version rollout.
Gate the one-way rewrite on a vector-specific capability or a negotiated
protocol version, while retaining the existing batch capability check.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoPaimonScan.java:
##########
@@ -0,0 +1,513 @@
+// 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.rules.rewrite;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.datasource.credentials.CredentialUtils;
+import org.apache.doris.datasource.credentials.VendedCredentialsFactory;
+import org.apache.doris.datasource.paimon.PaimonExternalCatalog;
+import org.apache.doris.datasource.paimon.PaimonExternalTable;
+import org.apache.doris.datasource.paimon.PaimonUtil;
+import org.apache.doris.datasource.paimon.source.PaimonVectorSearch;
+import org.apache.doris.datasource.property.storage.StorageProperties;
+import org.apache.doris.nereids.rules.Rule;
+import org.apache.doris.nereids.rules.RuleType;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.ComparisonPredicate;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.InPredicate;
+import org.apache.doris.nereids.trees.expressions.IsNull;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Not;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.InnerProductApproximate;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.L2DistanceApproximate;
+import org.apache.doris.nereids.trees.expressions.literal.ArrayLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.plans.AnnTopNInfo;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
+import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.thrift.TVectorMetric;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.MultisetType;
+import org.apache.paimon.types.RowType;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Push a primary-key vector (ANN) top-N down into the Paimon scan
(index-only).
+ *
+ * <p>Recognizes {@code TopN[order by dis] -> Project[dis := dist_fn(vec, q)]
-> [Filter] -> PaimonScan}
+ * where {@code dist_fn} is {@code l2_distance_approximate} (ASC) or
+ * {@code inner_product_approximate} (DESC) and the Paimon table has a
PK-vector
+ * index on {@code vec} with a matching distance metric.
+ *
+ * <p>The rewrite mirrors the internal-table ANN path (see
+ * {@code PushDownVectorTopNIntoOlapScan}): the scan is told to produce a
+ * reader-generated {@code __paimon_search_score_to_dis} column via the
PK-vector index, and
+ * every occurrence of {@code dist_fn(vec, q)} is replaced by that column's
slot. The
+ * order key slot keeps its identity, so the surrounding TopN merges/limits
unchanged.
+ * The distance always comes from the index, never from the raw vector.
+ *
+ * <p>No arithmetic is emitted here even though paimon-rust reports a score
rather than
+ * a distance: the BE reader undoes the metric-dependent score transform in
place while
+ * filling the block, exactly as the internal table sqrt's faiss's squared
distance inside
+ * the index layer. So the column arrives already holding what {@code dist_fn}
would
+ * return, and the plan stays a bare slot reference -- which also keeps {@code
dis}
+ * non-nullable (an expression over the score would not be, since {@code
Divide} and
+ * {@code Sqrt} are always nullable) and makes predicates like
+ * {@code dist_fn(vec, q) < 10} correct without any direction flip.
+ *
+ * <p>Being index-only is just an optimization here, not a precondition: if
nothing
+ * else references the raw vector column it is left unreferenced and dropped
by column
+ * pruning (pure index-only read); if the query still needs it ({@code SELECT
vec} or a
+ * predicate on {@code vec}), it stays a normal projected column and the BE
vector
+ * reader materializes it alongside the score. Either way ANN is applied --
the rewrite
+ * never falls back merely because the vector is read.
+ */
+public class PushDownVectorTopNIntoPaimonScan implements RewriteRuleFactory {
+
+ /** Doris column types the BE paimon-rust predicate converter can push; see
+ * PaimonRustPredicateConverter._is_supported_slot_type on the BE side. */
+ private static final Set<Type> VECTOR_FILTER_SUPPORTED_COLUMN_TYPES =
ImmutableSet.of(
+ Type.BOOLEAN, Type.TINYINT, Type.SMALLINT, Type.INT, Type.BIGINT,
+ Type.STRING, Type.DATE, Type.DATEV2);
+
+ @Override
+ public List<Rule> buildRules() {
+ return ImmutableList.of(
+ logicalTopN(logicalProject(logicalFileScan())).when(t ->
t.getOrderKeys().size() == 1).then(topN -> {
+ LogicalProject<LogicalFileScan> project = topN.child();
+ LogicalFileScan scan = project.child();
+ return pushDown(topN, project, scan, Optional.empty());
+ }).toRule(RuleType.PUSH_DOWN_VECTOR_TOPN_INTO_PAIMON_SCAN),
+ logicalTopN(logicalProject(logicalFilter(logicalFileScan())))
+ .when(t -> t.getOrderKeys().size() == 1).then(topN -> {
+ LogicalProject<LogicalFilter<LogicalFileScan>>
project = topN.child();
+ LogicalFilter<LogicalFileScan> filter =
project.child();
+ LogicalFileScan scan = filter.child();
+ return pushDown(topN, project, scan,
Optional.of(filter));
+
}).toRule(RuleType.PUSH_DOWN_VECTOR_TOPN_INTO_PAIMON_SCAN)
+ );
+ }
+
+ private Plan pushDown(
+ LogicalTopN<?> topN,
+ LogicalProject<?> project,
+ LogicalFileScan scan,
+ Optional<LogicalFilter<?>> optionalFilter) {
+ // Only Paimon external tables carry PK-vector indexes.
+ if (!(scan.getTable() instanceof PaimonExternalTable)) {
+ return null;
+ }
+
+ // The index-only search path is executed exclusively by the BE
paimon-rust reader,
+ // which only FileScannerV2 can select (the V1 FileScanner explicitly
rejects
+ // PAIMON_RUST). If the rust reader or the V2 scanner is disabled, the
split would
+ // fall through to the JNI reader, which ignores the vector payload
and would
+ // return a non-ANN scan with an empty distance column. Fall back to a
normal
+ // scan then.
+ ConnectContext ctx = ConnectContext.get();
+ if (ctx == null || ctx.getSessionVariable() == null
+ || !ctx.getSessionVariable().isEnablePaimonRustReader()
+ || !ctx.getSessionVariable().enableFileScannerV2) {
+ return null;
+ }
+
+ // An incremental scan (t@incr / BETWEEN) must not be rewritten into a
+ // PK-vector search: the VectorScan builder has no incremental mode,
so a
+ // vector payload would silently return snapshot rows for a query that
asked
+ // for changes. Keeping the query un-rewritten runs it as the ordinary
+ // incremental scan the user requested, with the distance computed in
Doris.
+ // (The scan node's vector gate keeps !isIncremental as the
authoritative
+ // backstop for any path that produces a vector split regardless.)
+ //
+ // The same holds for a pinned file-creation-time read state: the
ordinary
+ // path enforces `scan.file-creation-time-millis` by dropping manifest
+ // entries older than the bound, but the vector search plans its own
splits
+ // and never consults that bound, so old files' nearest neighbors
could leak
+ // into the result. Snapshot/tag/version selectors are fine -- they are
+ // resolved into the table handle before the vector search -- so only
the
+ // file-level bound forces the decline.
+ if (scan.getScanParams().isPresent()) {
+ TableScanParams scanParams = scan.getScanParams().get();
+ if (scanParams.incrementalRead()) {
+ return null;
+ }
+ Map<String, String> rawParams = scanParams.getMapParams();
+ if
(rawParams.containsKey(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())) {
+ return null;
+ }
+ }
+
+ // A bounded vector top-N is only correct when every filter conjunct is
+ // applied inside the search, before the limit is exhausted. The BE
+ // paimon-rust predicate converter skips conjuncts it cannot represent
(and
+ // the synthetic distance column is not in its field registry at all),
and
+ // the engine re-applies those residuals on the returned block --
after the
+ // top-K rows were already selected. A skipped residual can therefore
drop
+ // rows out of a bounded result: with candidate distances 0, 2 and 3, a
+ // `WHERE dis > 1 ORDER BY dis LIMIT 1` would fetch only the
distance-0 row,
+ // filter it out and return nothing instead of the distance-2 row. So
the
+ // rewrite only fires when each conjunct is provably of a form the
converter
+ // pushes: a simple comparison / IN / null-check on a table column
whose
+ // Doris type is in the converter's supported set (see
+ // PaimonRustPredicateConverter._is_supported_slot_type on BE).
Anything
+ // else -- an expression predicate, a predicate on the distance
itself, an
+ // unsupported column type -- declines the rewrite and the query runs
as an
+ // ordinary scan with the distance computed in Doris, which is always
+ // correct.
+ if (optionalFilter.isPresent() &&
!conjunctsRideIntoVectorSearch(optionalFilter.get())) {
+ return null;
+ }
+
+ // The order key must be a SlotReference produced by a Project alias.
+ Expression orderKey = topN.getOrderKeys().get(0).getExpr();
+ if (!(orderKey instanceof SlotReference)) {
+ return null;
+ }
+ SlotReference keySlot = (SlotReference) orderKey;
+ Alias orderKeyAlias = null;
+ Expression orderKeyExpr = null;
+ for (NamedExpression projection : project.getProjects()) {
+ if (projection.toSlot().equals(keySlot) && projection instanceof
Alias) {
+ orderKeyAlias = (Alias) projection;
+ orderKeyExpr = orderKeyAlias.child();
+ break;
+ }
+ }
+ if (orderKeyExpr == null) {
+ return null;
+ }
+
+ // l2 must sort ascending (smaller distance = closer); inner_product
descending.
+ boolean isAsc = topN.getOrderKeys().get(0).isAsc();
+ boolean l2Dist = orderKeyExpr instanceof L2DistanceApproximate;
+ boolean innerProduct = orderKeyExpr instanceof InnerProductApproximate;
+ if (!(l2Dist && isAsc) && !(innerProduct && !isAsc)) {
+ return null;
+ }
+
+ // The indexed column must be a direct slot reference. A Cast chain
over the
+ // column is NOT stripped: a legal cast can change vector values (for
example
+ // an ARRAY<FLOAT> -> ARRAY<INT> -> ARRAY<FLOAT> round trip loses
precision),
+ // and searching the original vectors while the query means the casted
ones
+ // changes both the distance and the row order. Doris's implicit
coercions do
+ // not produce a cast here for a matching ARRAY<FLOAT> column, so
anything
+ // wrapped in a Cast simply declines the rewrite.
+ Expression left;
+ Expression right;
+ if (l2Dist) {
+ left = ((L2DistanceApproximate) orderKeyExpr).left();
+ right = ((L2DistanceApproximate) orderKeyExpr).right();
+ } else {
+ left = ((InnerProductApproximate) orderKeyExpr).left();
+ right = ((InnerProductApproximate) orderKeyExpr).right();
+ }
+ if (!(left instanceof SlotReference && right.isConstant() && right
instanceof ArrayLiteral)) {
+ return null;
+ }
+ SlotReference vectorSlot = (SlotReference) left;
+ if (!vectorSlot.getOriginalColumn().isPresent() ||
!vectorSlot.getOriginalTable().isPresent()) {
+ return null;
+ }
+ String annColumnName = vectorSlot.getOriginalColumn().get().getName();
+
+ // Validate the PK-vector index and metric on the Paimon table.
+ PaimonExternalTable paimonTable = (PaimonExternalTable)
scan.getTable();
+ CoreOptions coreOptions;
+ try {
+ Table basePaimonTable =
paimonTable.getPaimonTable(Optional.empty());
+ coreOptions = CoreOptions.fromMap(basePaimonTable.options());
+ } catch (Throwable t) {
+ return null;
+ }
+ if (!coreOptions.primaryKeyVectorIndexEnabled()) {
+ return null;
+ }
+ boolean columnIndexed =
coreOptions.primaryKeyVectorIndexColumns().stream()
+ .anyMatch(c -> c.equalsIgnoreCase(annColumnName));
+ if (!columnIndexed) {
+ return null;
+ }
+ // v1 supports only l2 / inner_product; the function must match the
index metric.
+ String metric =
coreOptions.primaryKeyVectorDistanceMetric(annColumnName);
+ String expectedMetric = l2Dist ? "l2" : "inner_product";
+ if (!expectedMetric.equals(metric)) {
+ return null;
+ }
+
+ // Pre-flight the projection-dependent bounds the vector split would
hit at
+ // the scan node: a PK-vector split has no JNI fallback (it would
return an
+ // empty distance column), so a query whose projected columns would set
+ // projectedVariant -- or an ORC table with TIMESTAMP_LTZ in its
schema, the
+ // orc-ltz-shape bound -- must not be rewritten at all. Without the
rewrite
+ // those queries run through the ordinary paths, where they are
supported.
+ try {
+ if (projectedColumnsContainVariant(project, optionalFilter)
+ || orcSchemaContainsTimestampLtz(paimonTable)) {
+ return null;
+ }
+ } catch (Throwable t) {
+ return null;
+ }
+
+ // The thrift-level enum the AnnTopNInfo will carry. expectedMetric is
kept as a
+ // string for the comparison above (coreOptions returns a string), but
the
+ // AnnTopNInfo / wire contract uses TVectorMetric, so map once here.
DOT_PRODUCT
+ // is the thrift enum that paimon-rust's "inner_product" score
transform maps to
+ // (see the BE reader's parse_score_transform).
+ TVectorMetric expectedMetricEnum = l2Dist ? TVectorMetric.L2 :
TVectorMetric.DOT_PRODUCT;
+
+ // Pre-flight the rust eligibility the scan node will gate on. The
rewrite is
+ // one-way: a PK-vector split has no JNI fallback (the JNI reader
ignores the
+ // vector payload and would return a non-ANN scan with an empty
distance
+ // column), so the scan node can only fail loudly — a table whose gate
would
+ // fail (credential provider shape, REST-token IO, query-auth,
merge-option
+ // matrix, deletion-vector merge-on-read, scan-mode, storage scheme or
HDFS
+ // credentials, no rust-capable backend) must not be rewritten at all.
Without
+ // the rewrite the query still runs, computing the distance in Doris.
The
+ // projection-dependent bounds (ORC LTZ, projected variant) need the
final scan
+ // tuple and stay scan-node-side, and the scan node re-evaluates on the
+ // relation-option-processed table, so it stays authoritative for
t@options
+ // overrides this raw-table pre-flight cannot see.
+ try {
+ Table baseTable = paimonTable.getPaimonTable(Optional.empty());
+ PaimonExternalCatalog catalog = (PaimonExternalCatalog)
paimonTable.getCatalog();
+ Map<StorageProperties.Type, StorageProperties>
storagePropertiesMap =
+
VendedCredentialsFactory.getStoragePropertiesMapWithVendedCredentials(
+
catalog.getCatalogProperty().getMetastoreProperties(),
+
catalog.getCatalogProperty().getStoragePropertiesMap(),
+ baseTable);
+ Map<String, String> backendStorageProperties =
+
CredentialUtils.getBackendPropertiesFromStorageMap(storagePropertiesMap);
+ String tableLocation = baseTable instanceof FileStoreTable
+ ? ((FileStoreTable) baseTable).location().toString()
+ : baseTable.options().get("path");
+ if (!PaimonVectorSearch.vectorScanEligible(baseTable,
tableLocation,
+ backendStorageProperties,
+ Env.getCurrentSystemInfo().getAllClusterBackends(true))) {
+ return null;
+ }
+ } catch (Throwable t) {
+ return null;
+ }
+ // NOTE: the FE-side data-predicate interception is intentionally not
done. The
Review Comment:
[P2] Preflight the SDK's table-mode restriction for data filters
A supported PK-vector table with `merge-engine=first-row` and
`deletion-vectors.enabled=false` passes this rewrite for `WHERE tag < 3 ORDER
BY l2_distance_approximate(vec,q) LIMIT k`. Paimon 2.1 explicitly permits
FIRST_ROW vector indexes without deletion vectors, but pinned paimon-rust
`PkVectorSearchParams::resolve` rejects every data predicate unless deletion
vectors are enabled and merge-on-read is disabled. FE planning supplies only
partition filters, so the failure occurs after the expression is replaced, when
BE sends the successfully converted tag predicate. Decline this table/filter
combination before rewriting so the supported ordinary query can execute.
Unlike the prior residual-filter thread, this predicate converts completely;
the SDK rejects its table mode.
##########
be/src/format_v2/table/paimon_rust_table_reader.cpp:
##########
@@ -804,6 +1308,13 @@ std::vector<std::string>
PaimonRustTableReader::_build_read_columns() const {
if (column.is_partition_key) {
continue;
}
+ // The PK-vector search distance is reader-produced and auto-appended
by
+ // paimon-rust under its own field name; it is never a projectable
table
+ // column, so exclude it from the projection in every mode (in
non-vector
+ // mode it is simply absent).
+ if (column.name == kPaimonSearchDistanceColumn) {
Review Comment:
[P2] Skip the synthetic distance name only for a synthetic slot
This helper also builds ordinary Rust projections, and
`__paimon_search_score_to_dis` is a legal Paimon user-column name. On a
compatible logical Rust scan of `SELECT id, __paimon_search_score_to_dis FROM
t`, this unconditional exclusion requests only `id`; `_fill_non_arrow_columns`
then replaces the real nullable column's values with NULL/defaults. No ANN
rewrite or vector index is needed for this regression. Limit the synthetic
exclusion to the actual vector-produced slot and preserve physical columns with
the same name on ordinary scans; handle collisions before adding a synthetic
slot.
##########
regression-test/framework/src/main/groovy/org/apache/doris/regression/util/ResultUtils.groovy:
##########
@@ -59,8 +64,8 @@ class ResultUtils {
}
static void assertSparkDorisResultEquals(List<List<Object>> sparkRows,
List<List<Object>> dorisRows) {
- List<List<Object>> normalizedSparkRows = normalizeRows(sparkRows)
- List<List<Object>> normalizedDorisRows = normalizeRows(dorisRows)
+ List<List<Object>> normalizedSparkRows =
decodeNestedBinaryTokens(normalizeRows(sparkRows))
Review Comment:
[P2] Preserve binary identity during result normalization
`normalizeValue(byte[])` first converts scalar binary values to `0x...`
strings, so this new pass decodes scalar columns as well as nested tokens.
Distinct values such as the single bytes `0x80` and `0x81` both become the same
UTF-8 replacement character in `decodeHexString`, making
`assertSparkDorisResultEquals` pass despite different returned bytes. The same
information loss affects nested arbitrary binary data. Keep a lossless byte/hex
representation and use type-aware conversion for nested renderings instead of
decoding every matching string through replacement UTF-8.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]