github-actions[bot] commented on code in PR #69581:
URL: https://github.com/apache/doris/pull/69581#discussion_r4369909220


##########
gensrc/thrift/PlanNodes.thrift:
##########
@@ -475,13 +475,142 @@ struct TRemoteDorisFileDesc {
     6: optional string password
 }
 
+// The external search enums below spell out their values because those values 
are on the wire:
+// branch-4.1/4.2 declared the same enums without explicit values, so each 
member must keep its
+// implicit position (FLOAT16 = 0, FLOAT32 = 1, ...) on every branch.
+
+// Element type and byte layout of a vector search query. The encoded values 
use little-endian
+// byte order and contain exactly `dimension` elements of `element_type`.
+enum TVectorElementType {
+    FLOAT16 = 0,
+    FLOAT32 = 1,
+    FLOAT64 = 2,
+    UINT8 = 3,
+    INT8 = 4
+}
+
+enum TVectorMetric {
+    DEFAULT = 0,
+    L2 = 1,
+    COSINE = 2,
+    DOT_PRODUCT = 3,
+    HAMMING = 4
+}
+
+struct TSearchVector {
+    1: optional TVectorElementType element_type
+    2: optional i32 dimension
+    3: optional binary values
+    // Present only for one multi-vector query; values contains a row-major 
matrix.
+    4: optional i32 num_vectors
+}
+
+// Logical result parameters for one vector query. `top_k` is the number of 
rows returned after
+// skipping `offset`; an adapter may fetch more physical candidates to 
implement that contract.
+struct TVectorSearchParams {
+    1: optional string column
+    2: optional TSearchVector query_vector
+    3: optional i64 top_k
+    4: optional i64 offset
+    5: optional TVectorMetric metric

Review Comment:
   [P1] Retain the existing vector distance-bound fields
   
   Branch-4.1/4.2 already define [fields 6/7 as 
`distance_lower_bound`/`distance_upper_bound`](https://github.com/apache/doris/blob/a7ae6f4611e40dcf5e5444ae1d87bb4f79e4e490/gensrc/thrift/PlanNodes.thrift#L484-L492).
 Their FE writes these from `vector_search` properties without synthesizing a 
Doris residual. This definition discards both fields during Thrift 
deserialization, and the new reader never applies a native distance range while 
still accepting `schema_version=1`. During an FE-4.x/BE-master upgrade, an 
exact top-1 query whose nearest squared L2 distance is 1 and 
`distance_upper_bound=0.5` therefore returns that row instead of an empty 
result. Preserve the IDs and enforce the range, or explicitly reject 
unsupported bounded requests.



##########
be/src/format_v2/lance/lance_reader_helper.cpp:
##########
@@ -0,0 +1,750 @@
+// 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.
+
+#include "format_v2/lance/lance_reader_helper.h"
+
+#include <arrow/array.h>
+#include <arrow/builder.h>
+#include <arrow/c/bridge.h>
+#include <arrow/extension_type.h>
+#include <arrow/type.h>
+#include <arrow/util/key_value_metadata.h>
+#include <fmt/format.h>
+#include <lance/lance.h>
+
+#include <bit>
+#include <limits>
+#include <optional>
+#include <unordered_set>
+
+#include "common/config.h"
+#include "common/logging.h"
+#include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_factory.hpp"
+#include "core/data_type/data_type_map.h"
+#include "core/data_type/data_type_nothing.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_struct.h"
+#include "core/data_type_serde/arrow_validation.h"
+#include "exec/common/endian.h"
+
+namespace doris::format::lance {
+namespace {
+
+constexpr std::string_view ARROW_EXTENSION_NAME = "ARROW:extension:name";
+constexpr std::string_view ARROW_JSON_EXTENSION = "arrow.json";
+constexpr std::string_view LANCE_JSON_EXTENSION = "lance.json";
+constexpr std::string_view LANCE_BFLOAT16_EXTENSION = "lance.bfloat16";
+
+enum class LanceExtensionKind {
+    NONE,
+    JSON,
+    BFLOAT16,
+};
+
+int arrow_time_precision(arrow::TimeUnit::type unit) {
+    switch (unit) {
+    case arrow::TimeUnit::SECOND:
+        return 0;
+    case arrow::TimeUnit::MILLI:
+        return 3;
+    case arrow::TimeUnit::MICRO:
+    case arrow::TimeUnit::NANO:
+        return 6;
+    }
+    return 6;
+}
+
+// Extract and validate extension names from metadata and registered Arrow 
types.
+Status get_lance_extension(const std::shared_ptr<arrow::Field>& field,
+                           LanceExtensionKind* extension_kind,
+                           std::shared_ptr<arrow::DataType>* storage_type) {
+    DORIS_CHECK(field != nullptr);
+    DORIS_CHECK(extension_kind != nullptr);
+    DORIS_CHECK(storage_type != nullptr);
+    *extension_kind = LanceExtensionKind::NONE;
+    *storage_type = field->type();
+
+    std::optional<std::string> extension_name;
+    if (field->HasMetadata()) {
+        const auto metadata_name = 
field->metadata()->Get(ARROW_EXTENSION_NAME);
+        if (metadata_name.ok() && !metadata_name.ValueUnsafe().empty()) {
+            extension_name = metadata_name.ValueUnsafe();
+        }
+    }
+    if (field->type()->id() == arrow::Type::EXTENSION) {
+        const auto extension_type = 
std::dynamic_pointer_cast<arrow::ExtensionType>(field->type());
+        if (extension_type == nullptr) {
+            return Status::InvalidArgument("invalid Arrow extension type for 
Lance field '{}'",
+                                           field->name());
+        }
+        if (extension_name.has_value() && *extension_name != 
extension_type->extension_name()) {
+            return Status::InvalidArgument(
+                    "conflicting Arrow extension names for Lance field '{}': 
'{}' and '{}'",
+                    field->name(), *extension_name, 
extension_type->extension_name());
+        }
+        extension_name = extension_type->extension_name();
+        *storage_type = extension_type->storage_type();
+    }
+    if (!extension_name.has_value()) {
+        return Status::OK();
+    }
+
+    if (*extension_name == ARROW_JSON_EXTENSION) {
+        if ((*storage_type)->id() != arrow::Type::STRING &&
+            (*storage_type)->id() != arrow::Type::LARGE_STRING) {
+            return Status::NotSupported(
+                    "Arrow JSON extension for Lance field '{}' requires UTF8 
storage, got {}",
+                    field->name(), (*storage_type)->ToString());
+        }
+        *extension_kind = LanceExtensionKind::JSON;
+        return Status::OK();
+    }
+    if (*extension_name == LANCE_JSON_EXTENSION) {
+        if ((*storage_type)->id() != arrow::Type::LARGE_BINARY) {
+            return Status::NotSupported(
+                    "Lance JSON extension for field '{}' requires LARGE_BINARY 
storage, got {}",
+                    field->name(), (*storage_type)->ToString());
+        }
+        *extension_kind = LanceExtensionKind::JSON;
+        return Status::OK();
+    }
+    if (*extension_name == LANCE_BFLOAT16_EXTENSION) {
+        if ((*storage_type)->id() != arrow::Type::FIXED_SIZE_BINARY ||
+            
std::static_pointer_cast<arrow::FixedSizeBinaryType>(*storage_type)->byte_width()
 !=
+                    2) {
+            return Status::NotSupported(
+                    "Lance BFloat16 extension for field '{}' requires 
FIXED_SIZE_BINARY(2) "
+                    "storage, got {}",
+                    field->name(), (*storage_type)->ToString());
+        }
+        *extension_kind = LanceExtensionKind::BFLOAT16;
+        return Status::OK();
+    }
+    return Status::NotSupported("unsupported Lance Arrow extension type '{}' 
for field '{}'",
+                                *extension_name, field->name());
+}
+
+// Map Arrow fields recursively, preserving Null leaves in complex types.
+Status arrow_field_to_doris_type(const std::shared_ptr<arrow::Field>& field,
+                                 DataTypePtr* doris_type) {
+    const auto nullable_primitive = [&](PrimitiveType type, int precision = 0, 
int scale = 0,
+                                        int len = -1) {
+        *doris_type =
+                DataTypeFactory::instance().create_data_type(type, true, 
precision, scale, len);
+        return Status::OK();
+    };
+
+    LanceExtensionKind extension_kind;
+    std::shared_ptr<arrow::DataType> arrow_type;
+    RETURN_IF_ERROR(get_lance_extension(field, &extension_kind, &arrow_type));
+    switch (extension_kind) {
+    case LanceExtensionKind::JSON:
+        return nullable_primitive(TYPE_JSONB);
+    case LanceExtensionKind::BFLOAT16:
+        return nullable_primitive(TYPE_FLOAT);
+    case LanceExtensionKind::NONE:
+        break;
+    }
+    if (arrow_type->id() == arrow::Type::DICTIONARY) {
+        return Status::NotSupported("unsupported Lance Arrow dictionary type 
for field '{}': {}",
+                                    field->name(), arrow_type->ToString());
+    }
+
+    switch (arrow_type->id()) {
+    case arrow::Type::NA:
+        // Required Null leaves can bypass NullableSerDe through FE schema 
reconstruction.
+        // Reject them before Arrow NA buffers reach the Boolean-backed 
physical SerDe.
+        if (!field->nullable()) {
+            return Status::NotSupported("non-nullable Lance Arrow Null field: 
{}", field->name());
+        }
+        return nullable_primitive(TYPE_NULL);
+    case arrow::Type::BOOL:
+        return nullable_primitive(TYPE_BOOLEAN);
+    case arrow::Type::INT8:
+        return nullable_primitive(TYPE_TINYINT);
+    case arrow::Type::UINT8:
+    case arrow::Type::INT16:
+        return nullable_primitive(TYPE_SMALLINT);
+    case arrow::Type::UINT16:
+    case arrow::Type::INT32:
+        return nullable_primitive(TYPE_INT);
+    case arrow::Type::UINT32:
+    case arrow::Type::INT64:
+        return nullable_primitive(TYPE_BIGINT);
+    case arrow::Type::UINT64:
+        return nullable_primitive(TYPE_LARGEINT);
+    case arrow::Type::HALF_FLOAT:
+    case arrow::Type::FLOAT:
+        return nullable_primitive(TYPE_FLOAT);
+    case arrow::Type::DOUBLE:
+        return nullable_primitive(TYPE_DOUBLE);
+    case arrow::Type::STRING:
+    case arrow::Type::LARGE_STRING:
+        return nullable_primitive(TYPE_STRING);
+    case arrow::Type::BINARY:
+    case arrow::Type::LARGE_BINARY:
+        return nullable_primitive(TYPE_VARBINARY, 0, 0, 
std::numeric_limits<int32_t>::max());
+    case arrow::Type::FIXED_SIZE_BINARY: {
+        const auto binary = 
std::static_pointer_cast<arrow::FixedSizeBinaryType>(arrow_type);
+        return nullable_primitive(TYPE_VARBINARY, 0, 0, binary->byte_width());
+    }
+    case arrow::Type::DATE32:
+    case arrow::Type::DATE64:
+        return nullable_primitive(TYPE_DATEV2);
+    case arrow::Type::TIME32:
+    case arrow::Type::TIME64: {
+        const auto time = 
std::static_pointer_cast<arrow::TimeType>(arrow_type);
+        return nullable_primitive(TYPE_TIMEV2, 0, 
arrow_time_precision(time->unit()));
+    }
+    case arrow::Type::TIMESTAMP: {
+        const auto timestamp = 
std::static_pointer_cast<arrow::TimestampType>(arrow_type);
+        const auto timestamp_type =
+                timestamp->timezone().empty() ? TYPE_DATETIMEV2 : 
TYPE_TIMESTAMPTZ;
+        return nullable_primitive(timestamp_type, 0, 
arrow_time_precision(timestamp->unit()));
+    }
+    case arrow::Type::DURATION:
+        return nullable_primitive(TYPE_BIGINT);
+    case arrow::Type::DECIMAL128:
+    case arrow::Type::DECIMAL256: {
+        const auto decimal = 
std::static_pointer_cast<arrow::DecimalType>(arrow_type);
+        const int precision = decimal->precision();
+        const int scale = decimal->scale();
+        if (precision <= 0 || precision > arrow::Decimal256Type::kMaxPrecision 
|| scale < 0 ||
+            scale > precision) {
+            return Status::NotSupported(
+                    "unsupported Lance Arrow decimal type for field '{}': 
precision={}, scale={}",
+                    field->name(), precision, scale);
+        }
+        const PrimitiveType doris_decimal_type = precision <= 9    ? 
TYPE_DECIMAL32
+                                                 : precision <= 18 ? 
TYPE_DECIMAL64
+                                                 : precision <= 38 ? 
TYPE_DECIMAL128I
+                                                                   : 
TYPE_DECIMAL256;

Review Comment:
   [P1] Handle Decimal256 carriers before selecting a narrower SerDe
   
   A valid Lance `Decimal256(18,2)` is mapped to Doris DECIMAL64 here, but 
normalization leaves its `Decimal256Array` unchanged. 
`DataTypeDecimalSerDe<TYPE_DECIMAL64>::read_column_from_arrow` casts it to 
`arrow::DecimalArray` (Decimal128Array) and then calls `Value` on the null 
result, so reading a nonempty column crashes the BE. The [pinned Lance datatype 
encoding](https://github.com/lance-format/lance/blob/68c12dfd7efe02f90ad2d7f3a239a7eb64884e57/rust/lance-core/src/datatypes.rs#L335-L336)
 preserves the physical 256-bit width even when precision is <=38; current 
fixtures only use Decimal256 at precision 39/76. Normalize and checked-narrow 
that carrier, or support both physical widths in the SerDe, including nested 
leaves.



##########
be/src/format_v2/lance/lance_runtime_filter_helper.cpp:
##########
@@ -0,0 +1,537 @@
+// 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.
+
+#include "format_v2/lance/lance_runtime_filter_helper.h"
+
+#include <arrow/type.h>
+#include <cctz/time_zone.h>
+
+#include <algorithm>
+#include <cctype>
+#include <charconv>
+#include <limits>
+#include <optional>
+#include <set>
+#include <string_view>
+#include <system_error>
+
+#include "common/logging.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/field.h"
+#include "exprs/hybrid_set.h"
+#include "exprs/runtime_filter_expr.h"
+#include "exprs/vdirect_in_predicate.h"
+#include "exprs/vexpr_context.h"
+#include "exprs/vliteral.h"
+#include "exprs/vslot_ref.h"
+#include "format/format_common.h"
+#include "runtime/runtime_profile.h"
+#include "util/utf8_check.h"
+
+namespace doris::format::lance {
+namespace {
+
+constexpr std::string_view LANCE_RUNTIME_FILTER_CACHE_KEY_PREFIX = 
"lance-runtime-filter-sql:";
+
+std::string format_filter_ids(const std::vector<int>& filter_ids) {
+    std::string result;
+    for (const auto filter_id : filter_ids) {
+        if (!result.empty()) {
+            result.append(",");
+        }
+        result.append(std::to_string(filter_id));
+    }
+    return result;
+}
+
+std::string quote_sql_identifier(std::string_view identifier) {
+    // Lance SQL uses backticks for delimited identifiers. Escape an embedded 
backtick by doubling
+    // it, matching the SQL parser's quoted-identifier syntax.
+    std::string quoted("`");
+    quoted.reserve(identifier.size() + 2);
+    for (const char ch : identifier) {
+        if (ch == '`') {
+            quoted.append("``");
+        } else {
+            quoted.push_back(ch);
+        }
+    }
+    quoted.push_back('`');
+    return quoted;
+}
+
+const RuntimeFilterExpr* get_runtime_filter(const VExprContextSPtr& conjunct) {
+    if (conjunct == nullptr || conjunct->root() == nullptr) {
+        return nullptr;
+    }
+    return dynamic_cast<const RuntimeFilterExpr*>(conjunct->root().get());
+}
+
+std::string lowercase_ascii(std::string value) {
+    std::ranges::transform(value, value.begin(), [](const unsigned char ch) {
+        return static_cast<char>(std::tolower(ch));
+    });
+    return value;
+}
+
+std::string quote_string_value(std::string_view value) {
+    std::string quoted("'");
+    quoted.reserve(value.size() + 2);
+    for (const char ch : value) {
+        if (ch == '\'') {
+            quoted.append("''");
+        } else {
+            quoted.push_back(ch);
+        }
+    }
+    quoted.push_back('\'');
+    return quoted;
+}
+
+std::shared_ptr<arrow::DataType> find_unique_physical_type(const 
arrow::Schema& schema,
+                                                           std::string_view 
column_name) {
+    const auto fields = schema.GetAllFieldsByName(column_name);
+    return fields.size() == 1 ? fields.front()->type() : nullptr;
+}
+
+std::optional<int64_t> parse_int64(std::string_view value) {
+    int64_t parsed = 0;
+    const auto* begin = value.data();
+    const auto* end = begin + value.size();
+    const auto result = std::from_chars(begin, end, parsed);
+    if (result.ec != std::errc() || result.ptr != end) {
+        return std::nullopt;
+    }
+    return parsed;
+}
+
+bool integer_literal_fits_decimal_precision(std::string_view value, int32_t 
precision) {
+    if (!value.empty() && (value.front() == '-' || value.front() == '+')) {
+        value.remove_prefix(1);
+    }
+    const auto first_nonzero = value.find_first_not_of('0');
+    if (first_nonzero == std::string_view::npos) {
+        return true;
+    }
+    return value.size() - first_nonzero <= static_cast<size_t>(precision);
+}
+
+// This checks Lance planner coercion, not whether stored Lance values fit 
their own Arrow type.
+// Unsigned Arrow columns map to wider signed Doris types (for example UInt8 
-> SMALLINT), so an RF
+// produced from another input may contain a valid Doris value that cannot be 
coerced back to the
+// narrower physical type.
+bool integer_literal_fits_physical_type(std::string_view value,
+                                        const arrow::DataType& physical_type) {
+    const auto parsed = parse_int64(value);
+    if (!parsed.has_value()) {
+        return false;
+    }
+    const auto literal = *parsed;
+    switch (physical_type.id()) {
+    case arrow::Type::INT8:
+        return literal >= std::numeric_limits<int8_t>::min() &&
+               literal <= std::numeric_limits<int8_t>::max();
+    case arrow::Type::UINT8:
+        return literal >= 0 && literal <= std::numeric_limits<uint8_t>::max();
+    case arrow::Type::INT16:
+        return literal >= std::numeric_limits<int16_t>::min() &&
+               literal <= std::numeric_limits<int16_t>::max();
+    case arrow::Type::UINT16:
+        return literal >= 0 && literal <= std::numeric_limits<uint16_t>::max();
+    case arrow::Type::INT32:
+        return literal >= std::numeric_limits<int32_t>::min() &&
+               literal <= std::numeric_limits<int32_t>::max();
+    case arrow::Type::UINT32:
+        return literal >= 0 && literal <= std::numeric_limits<uint32_t>::max();
+    case arrow::Type::INT64:
+        return true;
+    case arrow::Type::UINT64:
+        // The pinned Lance SQL parser first parses a bare integer as i64. 
Values above INT64_MAX
+        // become Float64, which cannot be safely coerced to UInt64.
+        return literal >= 0;
+    default:
+        return false;
+    }
+}
+
+bool physical_type_supports_literal(PrimitiveType logical_type, 
std::string_view value,
+                                    const arrow::DataType& physical_type) {
+    switch (logical_type) {
+    case TYPE_BOOLEAN:
+        return physical_type.id() == arrow::Type::BOOL;
+    case TYPE_TINYINT:
+    case TYPE_SMALLINT:
+    case TYPE_INT:
+    case TYPE_BIGINT:
+    case TYPE_LARGEINT:
+        return integer_literal_fits_physical_type(value, physical_type);
+    case TYPE_FLOAT:
+        // Arrow HALF_FLOAT and FLOAT both materialize as Doris FLOAT, but the 
pinned Lance planner
+        // cannot coerce an SQL Int64/Float64 literal to Float16.
+        return physical_type.id() == arrow::Type::FLOAT;
+    case TYPE_DOUBLE:
+        return physical_type.id() == arrow::Type::DOUBLE;
+    case TYPE_DECIMALV2:
+    case TYPE_DECIMAL32:
+    case TYPE_DECIMAL64:
+    case TYPE_DECIMAL128I:
+    case TYPE_DECIMAL256: {
+        if (physical_type.id() != arrow::Type::DECIMAL128 &&
+            physical_type.id() != arrow::Type::DECIMAL256) {
+            return false;
+        }
+        const auto& decimal = static_cast<const 
arrow::DecimalType&>(physical_type);
+        // A scale-bearing bare SQL token is parsed as Float64, which the 
pinned planner cannot
+        // coerce to Decimal. Scale-zero values are safe only while they parse 
exactly as i64.
+        return decimal.scale() == 0 && parse_int64(value).has_value() &&
+               integer_literal_fits_decimal_precision(value, 
decimal.precision());
+    }
+    case TYPE_CHAR:
+    case TYPE_VARCHAR:
+    case TYPE_STRING:
+        return (physical_type.id() == arrow::Type::STRING ||
+                physical_type.id() == arrow::Type::LARGE_STRING) &&
+               value.find('\0') == std::string_view::npos &&
+               validate_utf8(value.data(), value.size());
+    case TYPE_DATE:
+    case TYPE_DATEV2:
+        // Date64 is only specified to contain a millisecond count that should 
be day-aligned.
+        // Doris materialization discards any sub-day remainder, so only 
Date32 is unconditionally
+        // comparison-equivalent.
+        return physical_type.id() == arrow::Type::DATE32;
+    case TYPE_DATETIME:
+    case TYPE_DATETIMEV2: {
+        if (physical_type.id() != arrow::Type::TIMESTAMP) {
+            return false;
+        }
+        const auto& timestamp = static_cast<const 
arrow::TimestampType&>(physical_type);
+        // Doris DATETIMEV2 stores at most microseconds, so a nanosecond 
predicate is not equivalent
+        // to evaluating the residual after Arrow-to-Doris materialization.
+        return timestamp.timezone().empty() && timestamp.unit() != 
arrow::TimeUnit::NANO;
+    }
+    default:
+        return false;
+    }
+}
+
+std::optional<std::string> to_lance_sql_literal(const VLiteral& literal,
+                                                const arrow::DataType& 
physical_type) {
+    const auto type = remove_nullable(literal.get_data_type());
+    auto options = DataTypeSerDe::get_default_format_options();
+    auto timezone = cctz::utc_time_zone();
+    options.timezone = &timezone;
+    const auto value = literal.value(options);
+    if (!physical_type_supports_literal(type->get_primitive_type(), value, 
physical_type)) {
+        // The SQL literal would either fail Lance planning or compare 
different physical values
+        // from the Doris residual. Keep the RF residual-only in either case.
+        return std::nullopt;
+    }
+    switch (type->get_primitive_type()) {
+    case TYPE_BOOLEAN: {
+        const auto normalized = lowercase_ascii(value);
+        if (normalized == "0" || normalized == "false") {
+            return "FALSE";
+        }
+        if (normalized == "1" || normalized == "true") {
+            return "TRUE";
+        }
+        // Do not pass an implementation-specific boolean spelling through to 
Lance SQL.
+        return std::nullopt;
+    }
+    case TYPE_TINYINT:
+    case TYPE_SMALLINT:
+    case TYPE_INT:
+    case TYPE_BIGINT:
+    case TYPE_LARGEINT:
+        return value;
+    case TYPE_FLOAT:
+    case TYPE_DOUBLE: {
+        const auto normalized = lowercase_ascii(value);
+        if (normalized.find("nan") != std::string::npos ||
+            normalized.find("inf") != std::string::npos) {
+            // Lance SQL has no portable scalar spelling with Doris-equivalent 
NaN/Inf semantics.
+            return std::nullopt;
+        }
+        return value;
+    }
+    case TYPE_DECIMALV2:
+    case TYPE_DECIMAL32:
+    case TYPE_DECIMAL64:
+    case TYPE_DECIMAL128I:
+    case TYPE_DECIMAL256:
+        return value;
+    case TYPE_CHAR:
+    case TYPE_VARCHAR:
+    case TYPE_STRING:
+        return quote_string_value(value);
+    case TYPE_DATE:
+    case TYPE_DATEV2:
+        return "DATE " + quote_string_value(value);
+    case TYPE_DATETIME:
+    case TYPE_DATETIMEV2:
+        return "TIMESTAMP " + quote_string_value(value);
+    default:
+        return std::nullopt;
+    }
+}
+
+template <PrimitiveType PT>
+std::optional<std::string> in_value_to_lance_sql_literal(const void* raw_value,
+                                                         const DataTypePtr& 
data_type,
+                                                         const 
arrow::DataType& physical_type) {
+    if (raw_value == nullptr || data_type == nullptr) {
+        // A null set entry needs SQL three-valued semantics and cannot be 
serialized as a scalar.
+        return std::nullopt;
+    }
+    Field field;
+    if constexpr (is_string_type(PT)) {
+        const auto* value = static_cast<const StringRef*>(raw_value);
+        using CppType = typename PrimitiveTypeTraits<PT>::CppType;
+        field = Field::create_field<PT>(CppType(value->data, value->size));
+    } else {
+        using CppType = typename PrimitiveTypeTraits<PT>::CppType;
+        field = Field::create_field<PT>(*static_cast<const 
CppType*>(raw_value));
+    }
+    return to_lance_sql_literal(VLiteral(data_type, field), physical_type);
+}
+
+std::optional<std::string> in_value_to_lance_sql_literal(PrimitiveType 
primitive_type,
+                                                         const void* raw_value,
+                                                         const DataTypePtr& 
data_type,
+                                                         const 
arrow::DataType& physical_type) {
+#define DISPATCH_IN_VALUE(TYPE) \
+    case TYPE:                  \
+        return in_value_to_lance_sql_literal<TYPE>(raw_value, data_type, 
physical_type)
+    switch (primitive_type) {
+        DISPATCH_IN_VALUE(TYPE_BOOLEAN);
+        DISPATCH_IN_VALUE(TYPE_TINYINT);
+        DISPATCH_IN_VALUE(TYPE_SMALLINT);
+        DISPATCH_IN_VALUE(TYPE_INT);
+        DISPATCH_IN_VALUE(TYPE_BIGINT);
+        DISPATCH_IN_VALUE(TYPE_LARGEINT);
+        DISPATCH_IN_VALUE(TYPE_FLOAT);
+        DISPATCH_IN_VALUE(TYPE_DOUBLE);
+        DISPATCH_IN_VALUE(TYPE_DATE);
+        DISPATCH_IN_VALUE(TYPE_DATETIME);
+        DISPATCH_IN_VALUE(TYPE_DATEV2);
+        DISPATCH_IN_VALUE(TYPE_DATETIMEV2);
+        DISPATCH_IN_VALUE(TYPE_CHAR);
+        DISPATCH_IN_VALUE(TYPE_VARCHAR);
+        DISPATCH_IN_VALUE(TYPE_STRING);
+        DISPATCH_IN_VALUE(TYPE_DECIMALV2);
+        DISPATCH_IN_VALUE(TYPE_DECIMAL32);
+        DISPATCH_IN_VALUE(TYPE_DECIMAL64);
+        DISPATCH_IN_VALUE(TYPE_DECIMAL128I);
+        DISPATCH_IN_VALUE(TYPE_DECIMAL256);
+    default:
+        return std::nullopt;
+    }
+#undef DISPATCH_IN_VALUE
+}
+
+std::optional<std::string> build_in_filter_sql(const VDirectInPredicate& 
predicate,
+                                               const arrow::Schema& 
physical_schema) {
+    if (predicate.get_num_children() != 1) {
+        return std::nullopt;
+    }
+    const auto slot = 
std::dynamic_pointer_cast<VSlotRef>(predicate.get_child(0));
+    const auto values = predicate.get_set_func();
+    if (slot == nullptr || slot->data_type() == nullptr || values == nullptr) {
+        // Only a direct slot-backed IN set has a stable Lance column/literal 
representation.
+        return std::nullopt;
+    }
+    if (values->contain_null()) {
+        // Dropping NULL would change the Doris IN-filter semantics.
+        return std::nullopt;
+    }
+
+    const auto data_type = remove_nullable(slot->data_type());
+    const auto physical_type = find_unique_physical_type(physical_schema, 
slot->column_name());
+    if (physical_type == nullptr) {
+        // A missing or ambiguous top-level field cannot be bound safely by 
column name.
+        return std::nullopt;
+    }
+    std::string expression("(" + quote_sql_identifier(slot->column_name()) + " 
IN (");
+    auto* iterator = values->begin();
+    bool first_value = true;
+    while (iterator != nullptr && iterator->has_next()) {
+        auto value = in_value_to_lance_sql_literal(
+                data_type->get_primitive_type(), iterator->get_value(), 
data_type, *physical_type);
+        if (!value.has_value()) {
+            // Partially pushing an IN set would introduce false negatives, so 
reject it as a unit.
+            return std::nullopt;
+        }
+        if (!first_value) {
+            expression.append(", ");
+        }
+        expression.append(*value);
+        first_value = false;
+        iterator->next();
+    }
+    if (first_value) {
+        // An empty IN set has no direct Lance SQL representation.
+        return std::nullopt;
+    }
+    expression.append("))");
+    return expression;
+}
+
+std::optional<std::string> build_range_filter_sql(const VExpr& predicate,
+                                                  const arrow::Schema& 
physical_schema) {
+    if ((predicate.op() != TExprOpcode::GE && predicate.op() != 
TExprOpcode::LE) ||
+        predicate.get_num_children() != 2) {
+        return std::nullopt;
+    }
+    const auto slot = 
std::dynamic_pointer_cast<VSlotRef>(predicate.get_child(0));
+    const auto literal = 
std::dynamic_pointer_cast<VLiteral>(predicate.get_child(1));
+    if (slot == nullptr || literal == nullptr) {
+        // Computed operands are intentionally left to Doris instead of 
reconstructing expression
+        // SQL.
+        return std::nullopt;
+    }
+    const auto physical_type = find_unique_physical_type(physical_schema, 
slot->column_name());
+    if (physical_type == nullptr) {
+        // A missing or ambiguous top-level field cannot be bound safely by 
column name.
+        return std::nullopt;
+    }
+    const auto sql_literal = to_lance_sql_literal(*literal, *physical_type);
+    if (!sql_literal.has_value()) {
+        // Preserve the Doris residual when its bound has no proven-equivalent 
physical literal.
+        return std::nullopt;
+    }
+    const auto* sql_operator = predicate.op() == TExprOpcode::GE ? ">=" : "<=";
+    return "(" + quote_sql_identifier(slot->column_name()) + " " + 
sql_operator + " " +

Review Comment:
   [P2] Preserve negative NaNs when pushing floating range filters
   
   For a nested-loop join `L.x > B.y` with finite `B.y=1`, FE can generate the 
MIN runtime filter `L.x >= 1`. A Lance FLOAT containing negative quiet-NaN bits 
`0xffc00000` satisfies both predicates in Doris, whose `Compare` treats every 
NaN as greatest. The pinned [Arrow floating 
comparison](https://github.com/apache/arrow-rs/blob/58.3.0/arrow-array/src/arithmetic.rs#L391-L401)
 uses total ordering, placing negative NaNs below finite values, and 
[DataFusion delegates GE directly to that 
kernel](https://github.com/apache/datafusion/blob/54.0.0/datafusion/physical-expr-common/src/datum.rs#L63-L82);
 this generated SQL therefore drops a matching probe row. Rejecting nonfinite 
bound literals does not protect against NaN input values. Keep floating range 
filters residual-only or widen them to preserve every Doris survivor, with 
differential negative-NaN coverage.



##########
be/src/format_v2/lance/lance_record_batch_converter.cpp:
##########
@@ -0,0 +1,261 @@
+// 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.
+
+#include "format_v2/lance/lance_record_batch_converter.h"
+
+#include <arrow/array.h>
+#include <arrow/memory_pool.h>
+#include <arrow/record_batch.h>
+#include <arrow/type.h>
+
+#include <limits>
+
+#include "common/consts.h"
+#include "common/exception.h"
+#include "core/block/block.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_string.h"
+#include "core/data_type/data_type_nullable.h"
+#include "format_v2/lance/lance_reader_helper.h"
+#include "runtime/exec_env.h"
+#include "runtime/runtime_state.h"
+#include "storage/utils.h"
+
+namespace doris::format::lance {
+namespace {
+
+constexpr size_t IGNORED_OUTPUT_INDEX = std::numeric_limits<size_t>::max();
+
+} // namespace
+
+arrow::MemoryPool* LanceRecordBatchConverter::_query_arrow_memory_pool(
+        RuntimeState* runtime_state) {
+    DORIS_CHECK(runtime_state != nullptr);
+    if (runtime_state->exec_env() != nullptr) {
+        if (auto* memory_pool = runtime_state->exec_env()->arrow_memory_pool();
+            memory_pool != nullptr) {
+            return memory_pool;
+        }
+    }
+    return arrow::default_memory_pool();
+}
+
+Status LanceRecordBatchConverter::init(RuntimeState* runtime_state,
+                                       const std::vector<ColumnDefinition>& 
projected_columns,
+                                       SearchKind search_kind) {
+    DORIS_CHECK(runtime_state != nullptr);
+    _search_kind = search_kind;
+    _timezone = runtime_state->timezone_obj();
+    _memory_pool = _query_arrow_memory_pool(runtime_state);
+    _output_columns.clear();
+    _output_columns.reserve(projected_columns.size());
+    _output_index_by_input_name.clear();
+    _output_index_by_input_name.reserve(projected_columns.size());
+    _global_rowid_output_index.reset();
+    reset_schema();
+
+    for (const auto& column : projected_columns) {
+        if (column.type == nullptr) {
+            return Status::InvalidArgument("Lance projected column '{}' has no 
type", column.name);
+        }
+        if (column.name.starts_with(BeConsts::GLOBAL_ROWID_COL)) {
+            if (_search_kind == SearchKind::NORMAL) {
+                return Status::NotSupported(
+                        "Lance global row id is currently supported only for 
external search");
+            }
+            if (_global_rowid_output_index.has_value()) {
+                return Status::InvalidArgument("duplicate Lance global row id 
projected column: {}",
+                                               column.name);
+            }
+            if (remove_nullable(column.type)->get_primitive_type() != 
TYPE_STRING) {
+                return Status::InvalidArgument(
+                        "Lance global row id column '{}' must have Doris 
STRING type, but was {}",
+                        column.name, column.type->get_name());
+            }
+            _global_rowid_output_index = _output_columns.size();
+            // Lance returns the native `_rowid`, while Doris exposes it 
through a generated
+            // global-rowid column. One input column cannot also satisfy a 
regular `_rowid`
+            // projection, so reject an existing binding instead of silently 
replacing it.
+            if (!_output_index_by_input_name
+                         .emplace(LANCE_ROW_ID_COLUMN, 
*_global_rowid_output_index)
+                         .second) {
+                return Status::InvalidArgument(
+                        "Lance global row id cannot be combined with projected 
column '{}'",
+                        LANCE_ROW_ID_COLUMN);
+            }
+            _output_columns.push_back({column.name, nullptr});
+            continue;
+        }
+        const auto output_index = _output_columns.size();
+        if (!_output_index_by_input_name.emplace(column.name, 
output_index).second) {
+            return Status::InvalidArgument("duplicate Lance projected column: 
{}", column.name);
+        }
+        if (_search_kind == SearchKind::VECTOR && column.name == 
LANCE_DISTANCE_COLUMN &&
+            remove_nullable(column.type)->get_primitive_type() != TYPE_FLOAT) {
+            return Status::InvalidArgument(
+                    "Lance vector search column '{}' must have Doris FLOAT 
type, but was {}",
+                    LANCE_DISTANCE_COLUMN, column.type->get_name());
+        }
+        if (_search_kind == SearchKind::FULL_TEXT && column.name == 
LANCE_SCORE_COLUMN &&
+            remove_nullable(column.type)->get_primitive_type() != TYPE_FLOAT) {
+            return Status::InvalidArgument(
+                    "Lance full-text search column '{}' must have Doris FLOAT 
type, but was {}",
+                    LANCE_SCORE_COLUMN, column.type->get_name());
+        }
+        _output_columns.push_back({column.name, column.type->get_serde()});
+    }
+    return Status::OK();
+}
+
+Status LanceRecordBatchConverter::_bind_schema(const 
std::shared_ptr<arrow::Schema>& schema) {
+    DORIS_CHECK(schema != nullptr);
+    if (_bound_schema == schema ||
+        (_bound_schema != nullptr && _bound_schema->Equals(*schema, true))) {
+        return Status::OK();
+    }
+
+    std::vector<size_t> 
arrow_column_to_output(static_cast<size_t>(schema->num_fields()),
+                                               IGNORED_OUTPUT_INDEX);
+    std::vector<LanceArrowArrayNormalizer> array_normalizer_for_arrow_column(
+            static_cast<size_t>(schema->num_fields()));
+    std::vector<bool> output_has_source_column(_output_columns.size(), false);
+    for (int arrow_index = 0; arrow_index < schema->num_fields(); 
++arrow_index) {
+        const auto& field = schema->field(arrow_index);
+        const auto output = _output_index_by_input_name.find(field->name());
+        if (output == _output_index_by_input_name.end()) {
+            const bool is_omitted_search_result =
+                    (_search_kind == SearchKind::VECTOR &&
+                     field->name() == LANCE_DISTANCE_COLUMN) ||
+                    (_search_kind == SearchKind::FULL_TEXT && field->name() == 
LANCE_SCORE_COLUMN);
+            // Lance may return this generated result even when Doris did not 
project it.
+            if (is_omitted_search_result) {
+                continue;
+            }
+            return Status::InternalError("Lance returned unknown column '{}'", 
field->name());
+        }
+        const auto output_index = output->second;
+        if (output_has_source_column[output_index]) {
+            return Status::InternalError("Lance returned duplicate column 
'{}'", field->name());
+        }
+        output_has_source_column[output_index] = true;
+        arrow_column_to_output[arrow_index] = output_index;
+        RETURN_IF_ERROR(LanceArrowArrayNormalizer::create(
+                field, 
&array_normalizer_for_arrow_column[static_cast<size_t>(arrow_index)]));
+    }
+    for (size_t output_index = 0; output_index < _output_columns.size(); 
++output_index) {
+        if (!output_has_source_column[output_index]) {
+            return Status::InternalError("Lance did not return requested 
column '{}'",
+                                         _output_columns[output_index].name);
+        }
+    }
+    _bound_schema = schema;
+    _arrow_column_to_output_index = std::move(arrow_column_to_output);
+    _array_normalizer_for_arrow_column = 
std::move(array_normalizer_for_arrow_column);
+    return Status::OK();
+}
+
+Status LanceRecordBatchConverter::convert_record_batch_to_block(
+        const std::shared_ptr<arrow::RecordBatch>& record_batch, Block* block,
+        const std::optional<GlobalRowIdContext>& global_rowid_context, size_t* 
rows) {
+    DORIS_CHECK(record_batch != nullptr);
+    DORIS_CHECK(block != nullptr);
+    DORIS_CHECK(rows != nullptr);
+    RETURN_IF_ERROR(_bind_schema(record_batch->schema()));
+
+    const auto row_count = static_cast<size_t>(record_batch->num_rows());
+    auto columns_guard = block->mutate_columns_scoped();
+    auto& columns = columns_guard.mutable_columns();
+    for (int arrow_index = 0; arrow_index < record_batch->num_columns(); 
++arrow_index) {
+        const auto output_index = 
_arrow_column_to_output_index[static_cast<size_t>(arrow_index)];
+        if (output_index == IGNORED_OUTPUT_INDEX) {
+            continue;
+        }
+        const auto& output = _output_columns[output_index];
+        const auto& arrow_column = record_batch->column(arrow_index);
+        if (is_global_rowid_output(output_index)) {
+            if (!global_rowid_context.has_value()) {
+                return Status::InvalidArgument(
+                        "Lance global row id requested without global row id 
context");
+            }
+            RETURN_IF_ERROR(_append_global_row_ids(arrow_column, 
columns[output_index],
+                                                   *global_rowid_context));
+            continue;
+        }
+        const auto& field = record_batch->schema()->field(arrow_index);
+        try {
+            if (arrow_column->type_id() == arrow::Type::NA) {
+                columns[output_index]->insert_many_defaults(row_count);
+                continue;
+            }
+            std::shared_ptr<arrow::Array> normalized_column;
+            RETURN_IF_ERROR(
+                    
_array_normalizer_for_arrow_column[static_cast<size_t>(arrow_index)]
+                            .normalize_for_doris(arrow_column, _memory_pool, 
&normalized_column));
+            RETURN_IF_ERROR(output.serde->read_column_from_arrow(

Review Comment:
   [P2] Check DATE32 conversion failures at the Lance boundary
   
   The new adapter advertises Arrow DATE32 as supported DATEV2, including valid 
external dates above Doris's maximum year 9999. For example, day count 
`2932897` is `10000-01-01`. The shared DATE32 Arrow SerDe ignores 
`get_date_from_daynr(false)` and appends its zero-initialized value, while the 
nullable wrapper copies the source's non-null validity bit. This call therefore 
reports success with a non-null `0000-00-00` value. DATE64 and native Parquet 
conversion already reject or explicitly null failed conversions. Add equivalent 
checked range handling to this path, including nested date leaves, and cover 
the first date above `9999-12-31`.



##########
be/src/format_v2/table/lance_reader.cpp:
##########
@@ -0,0 +1,1321 @@
+// 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.
+
+#include "format_v2/table/lance_reader.h"
+
+#include <arrow/array.h>
+#include <arrow/c/bridge.h>
+#include <arrow/record_batch.h>
+#include <arrow/type.h>
+#include <lance/lance.h>
+
+#include <algorithm>
+#include <bit>
+#include <cstring>
+#include <limits>
+#include <memory>
+
+#include "common/config.h"
+#include "common/logging.h"
+#include "exec/common/endian.h"
+#include "format_v2/lance/lance_reader_helper.h"
+#include "format_v2/lance/lance_runtime_filter_helper.h"
+#include "format_v2/lance/lance_session_manager.h"
+#include "runtime/file_scan_profile.h"
+#include "runtime/runtime_state.h"
+
+namespace doris::format::lance {
+
+LanceTableReader::~LanceTableReader() {
+    static_cast<void>(close());
+}
+
+Status LanceTableReader::fetch_schema(const TFileRangeDesc& range,
+                                      const TFileScanRangeParams& scan_params,
+                                      std::vector<std::string>* column_names,
+                                      std::vector<DataTypePtr>* column_types) 
const {
+    if (column_names == nullptr || column_types == nullptr) {
+        return Status::InvalidArgument("Lance schema output must not be null");
+    }
+    const auto& params = range.table_format_params.lance_params;
+    std::vector<std::string> storage_options;
+    RETURN_IF_ERROR(build_lance_storage_options(&scan_params, 
&storage_options));
+    std::vector<const char*> storage_option_ptrs;
+    storage_option_ptrs.reserve(storage_options.size() + 1);
+    for (const auto& option : storage_options) {
+        storage_option_ptrs.emplace_back(option.c_str());
+    }
+    storage_option_ptrs.emplace_back(nullptr);
+
+    std::unique_ptr<LanceDataset, LanceDatasetDeleter> dataset(
+            lance_dataset_open(params.dataset_uri.c_str(),
+                               storage_options.empty() ? nullptr : 
storage_option_ptrs.data(),
+                               static_cast<uint64_t>(params.version)));
+    if (dataset == nullptr) {
+        return lance_error("open Lance dataset for schema");
+    }
+
+    std::shared_ptr<arrow::Schema> schema;
+    RETURN_IF_ERROR(import_lance_dataset_schema(dataset.get(), &schema));
+    return convert_arrow_schema_to_doris(schema, column_names, column_types);
+}
+
+Status LanceTableReader::init(TableReadOptions&& options) {
+    RETURN_IF_ERROR(TableReader::init(std::move(options)));
+    DORIS_CHECK(_runtime_state != nullptr);
+    DORIS_CHECK(_scanner_profile != nullptr);
+    DORIS_CHECK(_scan_params != nullptr);
+    RETURN_IF_ERROR(_resolve_search_kind());
+
+    const auto& lance_scan_params = _scan_params->lance_scan_params;
+    ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, LANCE_READER_PROFILE,
+                               file_scan_profile::TABLE_READER, 1);
+    _dataset_open_time = ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceDatasetOpenTime",
+                                                    LANCE_READER_PROFILE, 1);
+    _arrow_to_doris_block_time = ADD_CHILD_TIMER_WITH_LEVEL(
+            _scanner_profile, "LanceArrowToDorisBlockTime", 
LANCE_READER_PROFILE, 1);
+    _data_cache_bytes_read_from_cache =
+            ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceDataCacheBytesReadFromCache",
+                                         TUnit::BYTES, LANCE_READER_PROFILE, 
1);
+    _data_cache_bytes_read_from_remote =
+            ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceDataCacheBytesReadFromRemote",
+                                         TUnit::BYTES, LANCE_READER_PROFILE, 
1);
+    if (_search_kind != SearchKind::NORMAL) {
+        RETURN_IF_ERROR(_validate_external_search_request());
+        const auto& request = lance_scan_params.external_search_request;
+        int64_t top_k;
+        int64_t offset;
+        if (_search_kind == SearchKind::VECTOR) {
+            const auto& vector = request.search_query.vector_search;
+            top_k = vector.top_k;
+            offset = vector.offset;
+            _scanner_profile->add_info_string("LanceSearchType", "VECTOR");
+        } else {
+            DORIS_CHECK(_search_kind == SearchKind::FULL_TEXT);
+            const auto& full_text = request.search_query.full_text_search;
+            top_k = full_text.top_k;
+            offset = full_text.offset;
+            _scanner_profile->add_info_string("LanceSearchType", "FULL_TEXT");
+            _scanner_profile->add_info_string(
+                    "LanceFtsCoverageMode",
+                    full_text.coverage_mode == TFtsCoverageMode::STRICT ? 
"STRICT" : "INDEX_ONLY");
+            if (full_text.query_type == TFtsQueryType::MATCH) {
+                _scanner_profile->add_info_string("LanceFtsQueryType", 
"MATCH");
+                _scanner_profile->add_info_string(
+                        "LanceFtsMatchOperator",
+                        full_text.match_operator == TFtsMatchOperator::AND ? 
"AND" : "OR");
+                _scanner_profile->add_info_string("LanceFtsMaxFuzzyDistance",
+                                                  
std::to_string(full_text.max_fuzzy_distance));
+            } else {
+                _scanner_profile->add_info_string("LanceFtsQueryType", 
"PHRASE");
+                _scanner_profile->add_info_string("LanceFtsPhraseSlop",
+                                                  
std::to_string(full_text.phrase_slop));
+            }
+        }
+        _scanner_profile->add_info_string("LanceTopK", std::to_string(top_k));
+        _scanner_profile->add_info_string("LanceOffset", 
std::to_string(offset));
+        _scanner_profile->add_info_string("LanceTopKPlusOffset", 
std::to_string(top_k + offset));
+    }
+    if (_scan_params->__isset.lance_scan_params &&
+        lance_scan_params.__isset.lance_substrait_filter) {
+        _scanner_profile->add_info_string("LancePushdownFormat", "SUBSTRAIT");
+        _scanner_profile->add_info_string(
+                "LanceSubstraitFilterBytes",
+                
std::to_string(lance_scan_params.lance_substrait_filter.size()));
+    }
+
+    return _record_batch_converter.init(_runtime_state, _projected_columns, 
_search_kind);
+}
+
+Status LanceTableReader::prepare_split(const SplitReadOptions& options) {
+    _close_scanner();
+    _eof = false;
+    _runtime_filter_cache = options.cache;
+
+    RETURN_IF_ERROR(TableReader::prepare_split(options));
+    if (current_split_pruned()) {
+        return Status::OK();
+    }
+    // COUNT(*)/COUNT(1) with no filter is served from Lance metadata. The 
base class already set
+    // _remaining_table_level_count from the split's table_level_row_count, so 
skip opening any
+    // dataset scanner; get_block() synthesizes the counted rows.
+    if (_is_table_level_count_active()) {
+        return Status::OK();
+    }
+    if (_record_batch_converter.requires_global_rowid() && 
!_global_rowid_context.has_value()) {
+        return Status::InvalidArgument(
+                "Lance global row id requested without global row id context");
+    }
+
+    RETURN_IF_ERROR(_ensure_dataset_open(options.current_range));
+    RETURN_IF_ERROR(_open_scanner(options.current_range));
+    return Status::OK();
+}
+
+Status LanceTableReader::get_block(Block* block, bool* eos) {
+    DORIS_CHECK(block != nullptr);
+    DORIS_CHECK(eos != nullptr);
+    DORIS_CHECK(block->columns() == _projected_columns.size());
+    *eos = false;
+
+    if (_eof) {
+        *eos = true;
+        return Status::OK();
+    }
+    // Metadata COUNT(*) split: no scanner is opened. Emit synthetic rows for 
the upper COUNT
+    // operator directly from the row count the base class parsed out of the 
split.
+    if (_is_table_level_count_active()) {
+        return _read_table_level_count(block, eos);
+    }
+    if (_scanner == nullptr) {
+        return Status::InternalError("Lance scanner is not initialized for the 
current split");
+    }
+
+    const auto target_rows = std::max<size_t>(1, _scanner_batch_size);
+    while (true) {
+        block->clear_column_data(_projected_columns.size());
+        size_t raw_rows = 0;
+        while (raw_rows < target_rows) {
+            if (_io_ctx != nullptr && _io_ctx->should_stop) {
+                _eof = true;
+                _close_scanner();
+                *eos = true;
+                return Status::OK();
+            }
+
+            LanceBatch* raw_batch = nullptr;
+            int32_t scan_status = 0;
+            {
+                SCOPED_TIMER(_scanner_read_time);
+                scan_status = lance_scanner_next(_scanner, &raw_batch);
+            }
+            if (scan_status == 1) {
+                _eof = true;
+                _close_scanner();
+                break;
+            }
+            if (scan_status != 0 || raw_batch == nullptr) {
+                return lance_error("read next Lance batch");
+            }
+
+            std::unique_ptr<LanceBatch, LanceBatchDeleter> batch(raw_batch);
+            size_t rows = 0;
+            {
+                SCOPED_TIMER(_arrow_to_doris_block_time);
+                RETURN_IF_ERROR(_fill_block_from_lance_batch(batch.get(), 
block, &rows));
+            }
+            _record_scan_rows(rows);
+            raw_rows += rows;
+        }
+
+        if (raw_rows == 0) {
+            DORIS_CHECK(_eof);
+            *eos = true;
+            return Status::OK();
+        }
+
+        if (block->rows() > 0) {
+            // Preserve a non-empty final block. The next get_block() observes 
`_eof` and reports
+            // split EOF, matching the generic reader contract.
+            return Status::OK();
+        }
+        if (_eof) {
+            *eos = true;
+            return Status::OK();
+        }
+    }
+}
+
+Status LanceTableReader::read_by_row_ids(const TFileRangeDesc& range,
+                                         const std::vector<uint64_t>& row_ids, 
Block* block) {
+    DORIS_CHECK(block != nullptr);
+    DORIS_CHECK(block->columns() == _projected_columns.size());
+    if (row_ids.empty()) {
+        return Status::OK();
+    }
+    if (_row_id_take_read_time == nullptr) {
+        _row_id_take_read_time = ADD_CHILD_TIMER_WITH_LEVEL(
+                _scanner_profile, "LanceRowIdTakeReadTime", 
LANCE_READER_PROFILE, 1);
+    }
+    if (_row_id_fetch_total_time == nullptr) {
+        _row_id_fetch_total_time = ADD_CHILD_TIMER_WITH_LEVEL(
+                _scanner_profile, "LanceRowIdFetchTotalTime", 
LANCE_READER_PROFILE, 1);
+    }
+    auto* fetch_calls = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceRowIdFetchCalls",
+                                                     TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    auto* fetch_rows = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceRowIdFetchRows",
+                                                    TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    SCOPED_TIMER(_row_id_fetch_total_time);
+
+    // Phase-two row fetch does not execute FTS, so a reader created only for 
take_rows must not
+    // collect query-specific global statistics.
+    RETURN_IF_ERROR(_ensure_dataset_open(range, false));
+    std::vector<const char*> columns;
+    columns.reserve(_projected_columns.size() + 1);
+    for (const auto& column : _projected_columns) {
+        columns.emplace_back(column.name.c_str());
+    }
+    columns.emplace_back(nullptr);
+
+    ArrowArrayStream stream {};
+    int32_t take_rows_status = 0;
+    {
+        SCOPED_TIMER(_row_id_take_read_time);
+        COUNTER_UPDATE(fetch_calls, 1);
+        take_rows_status = lance_dataset_take_rows(_dataset, row_ids.data(), 
row_ids.size(),
+                                                   columns.data(), &stream);
+    }
+    if (take_rows_status != 0) {
+        if (stream.release != nullptr) {
+            stream.release(&stream);
+        }
+        return lance_error("take Lance rows by row id");
+    }
+    auto imported_reader = arrow::ImportRecordBatchReader(&stream);
+    if (!imported_reader.ok()) {
+        if (stream.release != nullptr) {
+            stream.release(&stream);
+        }
+        return Status::InternalError("import Lance take-rows stream failed: 
{}",
+                                     imported_reader.status().message());
+    }
+
+    size_t fetched_rows = 0;
+    auto batch_reader = std::move(imported_reader).ValueUnsafe();
+    while (true) {
+        std::shared_ptr<arrow::RecordBatch> record_batch;
+        arrow::Status read_status;
+        {
+            // Lance may materialize take_rows lazily while its Arrow stream 
is consumed.
+            SCOPED_TIMER(_row_id_take_read_time);
+            read_status = batch_reader->ReadNext(&record_batch);
+        }
+        if (!read_status.ok()) {
+            return Status::InternalError("read Lance take-rows batch failed: 
{}",
+                                         read_status.message());
+        }
+        if (record_batch == nullptr) {
+            break;
+        }
+        size_t rows = 0;
+        {
+            SCOPED_TIMER(_arrow_to_doris_block_time);
+            
RETURN_IF_ERROR(_record_batch_converter.convert_record_batch_to_block(
+                    record_batch, block, _global_rowid_context, &rows));
+        }
+        fetched_rows += rows;
+        COUNTER_UPDATE(fetch_rows, rows);
+    }
+    if (fetched_rows != row_ids.size()) {
+        return Status::InternalError("Lance row-id fetch returned {} rows for 
{} requested row ids",
+                                     fetched_rows, row_ids.size());
+    }
+    return Status::OK();
+}
+
+Status LanceTableReader::abort_split() {
+    _close_scanner();
+    _eof = true;
+    return TableReader::abort_split();
+}
+
+Status LanceTableReader::close() {
+    _close_scanner();
+    _close_dataset();
+    _opened_dataset_key.reset();
+    _eof = true;
+    return TableReader::close();
+}
+
+Status LanceTableReader::_resolve_search_kind() {
+    DORIS_CHECK(_scan_params != nullptr);
+    _search_kind = SearchKind::NORMAL;
+    if (!_scan_params->__isset.lance_scan_params) {
+        return Status::OK();
+    }
+    const auto& lance_scan_params = _scan_params->lance_scan_params;
+    if (!lance_scan_params.__isset.external_search_request) {
+        return Status::OK();
+    }
+    const auto& request = lance_scan_params.external_search_request;
+    if (!request.__isset.search_query) {
+        return Status::InvalidArgument("external search request requires 
search_query");
+    }
+    const bool has_vector = request.search_query.__isset.vector_search;
+    const bool has_full_text = request.search_query.__isset.full_text_search;
+    if (has_vector == has_full_text) {
+        return Status::InvalidArgument("external search query must set exactly 
one search kind");
+    }
+    _search_kind = has_vector ? SearchKind::VECTOR : SearchKind::FULL_TEXT;
+    return Status::OK();
+}
+
+Status LanceTableReader::_validate_external_search_request() const {
+    // FE validates requests produced by the search TVFs, but this reader 
consumes a deserialized
+    // Thrift boundary. Recheck structural invariants and values used for 
allocation, pointer
+    // arithmetic, C-string calls, and narrowing conversions before accessing 
them below.
+    DORIS_CHECK(_scan_params != nullptr);
+    DORIS_CHECK(_scan_params->__isset.lance_scan_params);
+    const auto& lance_scan_params = _scan_params->lance_scan_params;
+    DORIS_CHECK(lance_scan_params.__isset.external_search_request);
+    if (lance_scan_params.__isset.lance_substrait_filter) {
+        return Status::InvalidArgument(
+                "Lance external search cannot combine its pre-search filter 
with "
+                "lance_substrait_filter");
+    }
+
+    const auto& request = lance_scan_params.external_search_request;
+    if (request.schema_version != 1) {
+        return Status::NotSupported("unsupported external search schema 
version: {}",
+                                    request.schema_version);
+    }
+    DORIS_CHECK(request.__isset.search_query);
+    DORIS_CHECK(_search_kind != SearchKind::NORMAL);
+    constexpr auto UINT32_MAX_VALUE = 
static_cast<int64_t>(std::numeric_limits<uint32_t>::max());
+    if (_search_kind == SearchKind::VECTOR) {
+        const auto& vector = request.search_query.vector_search;
+        if (!vector.__isset.column || vector.column.empty() ||
+            vector.column.find('\0') != std::string::npos) {
+            return Status::InvalidArgument("Lance vector search requires a 
non-empty column");
+        }
+        if (!vector.__isset.query_vector) {
+            return Status::InvalidArgument("Lance vector search requires a 
query vector");
+        }
+        const auto& query_vector = vector.query_vector;
+        if (!query_vector.__isset.element_type || 
!query_vector.__isset.dimension ||
+            !query_vector.__isset.values) {
+            return Status::InvalidArgument(
+                    "Lance query vector requires element_type, dimension, and 
values");
+        }
+        if (query_vector.dimension <= 0) {
+            return Status::InvalidArgument("Lance query vector dimension must 
be positive: {}",
+                                           query_vector.dimension);
+        }
+        const auto element_width = 
lance_vector_element_width(query_vector.element_type);
+        if (element_width == 0) {
+            return Status::NotSupported("unsupported Lance query vector 
element type: {}",
+                                        
static_cast<int>(query_vector.element_type));
+        }
+        const bool multi_vector = query_vector.__isset.num_vectors;
+        // The optional count distinguishes a query matrix, including a 
one-row matrix.
+        if (multi_vector && query_vector.num_vectors <= 0) {
+            return Status::InvalidArgument(
+                    "Lance multi-vector queries require positive num_vectors");
+        }
+        if (multi_vector && (query_vector.element_type == 
TVectorElementType::UINT8 ||
+                             query_vector.element_type == 
TVectorElementType::INT8 ||
+                             (vector.__isset.metric && vector.metric == 
TVectorMetric::HAMMING))) {
+            return Status::NotSupported(
+                    "Lance multi-vector search requires floating-point vectors 
and l2, cosine, or "
+                    "dot");
+        }
+        const auto dimension = static_cast<size_t>(query_vector.dimension);
+        const auto count = multi_vector ? 
static_cast<size_t>(query_vector.num_vectors) : 1;
+        if (dimension > std::numeric_limits<size_t>::max() / count / 
element_width ||
+            query_vector.values.size() != dimension * count * element_width) {
+            return Status::InvalidArgument(
+                    "Lance query vector byte size {} does not match {} vectors 
of dimension {} and "
+                    "element width {}",
+                    query_vector.values.size(), count, dimension, 
element_width);
+        }
+        if (!vector.__isset.top_k || vector.top_k <= 0) {
+            return Status::InvalidArgument("Lance vector search top_k must be 
positive");
+        }
+        if (!vector.__isset.offset || vector.offset < 0) {
+            return Status::InvalidArgument("Lance vector search offset must be 
non-negative");
+        }
+        if (vector.offset > UINT32_MAX_VALUE || vector.top_k > 
UINT32_MAX_VALUE - vector.offset) {
+            return Status::InvalidArgument(
+                    "Lance vector search top_k + offset exceeds uint32 range");
+        }
+        // Match FE/C API limits before constructing the per-subvector ANN 
plan branches.
+        constexpr int MAX_QUERY_VECTORS = 128;
+        constexpr int64_t MAX_QUERY_VECTOR_CANDIDATES = 100000;
+        const auto refine_factor =
+                request.__isset.vector_search_options &&
+                                
request.vector_search_options.__isset.refine_factor
+                        ? request.vector_search_options.refine_factor
+                        : 1;
+        if (multi_vector &&
+            (query_vector.num_vectors > MAX_QUERY_VECTORS || refine_factor <= 
0 ||
+             vector.top_k + vector.offset > MAX_QUERY_VECTOR_CANDIDATES / 
refine_factor ||
+             query_vector.num_vectors >
+                     MAX_QUERY_VECTOR_CANDIDATES / (vector.top_k + 
vector.offset))) {
+            return Status::InvalidArgument(
+                    "multi-vector query exceeds 128 subvectors or 100000 
subvector-candidates");
+        }
+    } else {
+        DORIS_CHECK(_search_kind == SearchKind::FULL_TEXT);
+        const auto& full_text = request.search_query.full_text_search;
+        if (!full_text.__isset.column || full_text.column.empty() ||
+            full_text.column.find('\0') != std::string::npos) {
+            return Status::InvalidArgument("Lance full-text search requires a 
non-empty column");
+        }
+        if (!full_text.__isset.query || full_text.query.empty() ||
+            full_text.query.find('\0') != std::string::npos) {
+            return Status::InvalidArgument("Lance full-text search requires a 
non-empty query");
+        }
+        if (!full_text.__isset.top_k || full_text.top_k <= 0) {
+            return Status::InvalidArgument("Lance full-text search top_k must 
be positive");
+        }
+        if (!full_text.__isset.offset || full_text.offset < 0) {
+            return Status::InvalidArgument("Lance full-text search offset must 
be non-negative");
+        }
+        if (full_text.offset > UINT32_MAX_VALUE ||
+            full_text.top_k > UINT32_MAX_VALUE - full_text.offset) {
+            return Status::InvalidArgument(
+                    "Lance full-text search top_k + offset exceeds uint32 
range");
+        }
+        if (!full_text.__isset.coverage_mode ||
+            (full_text.coverage_mode != TFtsCoverageMode::STRICT &&
+             full_text.coverage_mode != TFtsCoverageMode::INDEX_ONLY)) {
+            return Status::InvalidArgument(
+                    "Lance full-text search requires STRICT or INDEX_ONLY 
coverage_mode");
+        }
+        if (full_text.__isset.global_statistics && 
full_text.global_statistics.empty()) {
+            return Status::InvalidArgument(
+                    "Lance full-text search global_statistics must not be 
empty when set");
+        }
+        if (!full_text.__isset.query_type || (full_text.query_type != 
TFtsQueryType::MATCH &&
+                                              full_text.query_type != 
TFtsQueryType::PHRASE)) {
+            return Status::InvalidArgument(
+                    "Lance full-text search requires MATCH or PHRASE 
query_type");
+        }
+        if (full_text.query_type == TFtsQueryType::MATCH) {
+            if (!full_text.__isset.match_operator ||
+                (full_text.match_operator != TFtsMatchOperator::OR &&
+                 full_text.match_operator != TFtsMatchOperator::AND)) {
+                return Status::InvalidArgument(
+                        "Lance MATCH query requires OR or AND match_operator");
+            }
+            if (!full_text.__isset.max_fuzzy_distance || 
full_text.max_fuzzy_distance < 0) {
+                return Status::InvalidArgument(
+                        "Lance MATCH query max_fuzzy_distance must be 
non-negative");
+            }
+            if (full_text.max_fuzzy_distance != 0) {
+                return Status::NotSupported(
+                        "Lance prepared FTS does not yet support 
max_fuzzy_distance={}",
+                        full_text.max_fuzzy_distance);
+            }
+            if (full_text.__isset.phrase_slop) {
+                return Status::InvalidArgument("Lance MATCH query cannot set 
phrase_slop");
+            }
+        } else {
+            if (!full_text.__isset.phrase_slop || full_text.phrase_slop < 0) {
+                return Status::InvalidArgument(
+                        "Lance PHRASE query phrase_slop must be non-negative");
+            }
+            if (full_text.__isset.match_operator || 
full_text.__isset.max_fuzzy_distance) {
+                return Status::InvalidArgument(
+                        "Lance PHRASE query cannot set MATCH-only parameters");
+            }
+        }
+        if (request.__isset.vector_search_options) {
+            return Status::InvalidArgument(
+                    "Lance full-text search cannot set vector_search_options");
+        }
+    }
+
+    if (request.__isset.search_filter) {
+        const auto& filter = request.search_filter;
+        if (!filter.__isset.format || !filter.__isset.payload || 
filter.payload.empty()) {
+            return Status::InvalidArgument(
+                    "external search filter requires format and non-empty 
payload");
+        }
+        if (filter.format != TSearchFilterFormat::SQL) {
+            return Status::NotSupported("unsupported external search filter 
format: {}",
+                                        static_cast<int>(filter.format));
+        }
+        if (filter.payload.find('\0') != std::string::npos) {
+            return Status::InvalidArgument("Lance SQL search filter contains 
an embedded NUL byte");
+        }
+    }
+
+    if (_search_kind == SearchKind::VECTOR && 
request.__isset.vector_search_options) {
+        const auto& options = request.vector_search_options;
+        if (options.__isset.query_parallelism && options.query_parallelism < 
-1) {
+            return Status::InvalidArgument("Lance query_parallelism must be 
-1, 0, or positive");
+        }
+        if (options.__isset.nprobes && options.nprobes <= 0) {
+            return Status::InvalidArgument("Lance nprobes must be positive");
+        }
+        if (options.__isset.refine_factor && options.refine_factor <= 0) {
+            return Status::InvalidArgument("Lance refine_factor must be 
positive");
+        }
+        if (options.__isset.ef && options.ef <= 0) {
+            return Status::InvalidArgument("Lance ef must be positive");
+        }
+    }
+    return Status::OK();
+}
+
+Status LanceTableReader::_ensure_dataset_open(const TFileRangeDesc& range,
+                                              bool prepare_fts_context) {
+    DatasetKey key;
+    RETURN_IF_ERROR(_dataset_key(range, &key));
+    if (_dataset == nullptr) {
+        RETURN_IF_ERROR(_open_dataset(key));
+        _opened_dataset_key = key;
+    } else if (!_opened_dataset_key.has_value() || *_opened_dataset_key != 
key) {
+        return Status::InvalidArgument(
+                "Lance reader cannot mix dataset snapshots or storage 
options");
+    }
+    if (_search_kind == SearchKind::FULL_TEXT && prepare_fts_context &&
+        _fts_query_context == nullptr) {
+        RETURN_IF_ERROR(_prepare_fts_query_context());
+    }
+    return Status::OK();
+}
+
+Status LanceTableReader::_open_dataset(const DatasetKey& key) {
+    std::vector<const char*> storage_option_ptrs;
+    storage_option_ptrs.reserve(key.storage_options.size() + 1);
+    for (const auto& option : key.storage_options) {
+        storage_option_ptrs.emplace_back(option.c_str());
+    }
+    storage_option_ptrs.emplace_back(nullptr);
+
+    std::unique_ptr<LanceDataset, LanceDatasetDeleter> dataset;
+    {
+        SCOPED_TIMER(_dataset_open_time);
+        LanceDataset* raw_dataset = nullptr;
+        RETURN_IF_ERROR(LanceSessionManager::instance().open_dataset(
+                key.uri.c_str(), key.storage_options.empty() ? nullptr : 
storage_option_ptrs.data(),
+                static_cast<uint64_t>(key.version), &raw_dataset));
+        dataset.reset(raw_dataset);
+    }
+    _dataset = dataset.release();
+    return Status::OK();
+}
+
+Status LanceTableReader::_prepare_fts_query_context() {
+    DORIS_CHECK(_dataset != nullptr);
+    DORIS_CHECK(_fts_query_context == nullptr);
+    DORIS_CHECK(_scan_params != nullptr);
+    const auto& full_text =
+            
_scan_params->lance_scan_params.external_search_request.search_query.full_text_search;
+    if (full_text.__isset.global_statistics) {
+        return Status::NotSupported(
+                "Lance FE-provided FTS global statistics require a lance-c 
consumer API");
+    }
+    const auto coverage_mode = full_text.coverage_mode == 
TFtsCoverageMode::STRICT
+                                       ? LANCE_FTS_COVERAGE_STRICT
+                                       : LANCE_FTS_COVERAGE_INDEX_ONLY;
+    // Keep statistics preparation at the reader/scanner lifetime today. A 
future FE-provided
+    // opaque statistics payload should enter through this boundary and create 
the same context,
+    // leaving segment-scoped scanner execution unchanged.
+    if (full_text.query_type == TFtsQueryType::MATCH) {
+        const auto match_operator = full_text.match_operator == 
TFtsMatchOperator::AND
+                                            ? LANCE_FTS_MATCH_OPERATOR_AND
+                                            : LANCE_FTS_MATCH_OPERATOR_OR;
+        _fts_query_context = lance_dataset_prepare_fts_match_query(
+                _dataset, full_text.column.c_str(), full_text.query.c_str(), 
match_operator,
+                static_cast<uint32_t>(full_text.max_fuzzy_distance), 
coverage_mode);
+    } else {
+        DORIS_CHECK(full_text.query_type == TFtsQueryType::PHRASE);
+        _fts_query_context = lance_dataset_prepare_fts_phrase_query(
+                _dataset, full_text.column.c_str(), full_text.query.c_str(), 
full_text.phrase_slop,
+                coverage_mode);
+    }
+    if (_fts_query_context == nullptr) {
+        return lance_error("prepare Lance FTS query context");
+    }
+    return Status::OK();
+}
+
+void LanceTableReader::_init_scanner_profile() {
+    if (_scanner_configure_time != nullptr) {
+        return;
+    }
+
+    _scanner_configure_time = ADD_CHILD_TIMER_WITH_LEVEL(
+            _scanner_profile, "LanceScannerConfigureTime", 
LANCE_READER_PROFILE, 1);
+    _runtime_filter_sql_time = ADD_CHILD_TIMER_WITH_LEVEL(
+            _scanner_profile, "LanceRuntimeFilterSqlTime", 
LANCE_READER_PROFILE, 1);
+    _scanner_read_time = ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceScannerReadTime",
+                                                    LANCE_READER_PROFILE, 1);
+    _execution_iops = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceExecutionIOOps",
+                                                   TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    _execution_requests = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceExecutionIORequests",
+                                                       TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+    _execution_bytes_read = ADD_CHILD_COUNTER_WITH_LEVEL(
+            _scanner_profile, "LanceExecutionIOBytesRead", TUnit::BYTES, 
LANCE_READER_PROFILE, 1);
+    _index_partition_cache_miss_loads =
+            ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIndexPartitionCacheMissLoads",
+                                         TUnit::UNIT, LANCE_READER_PROFILE, 1);
+    _index_comparisons = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIndexComparisons",
+                                                      TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+
+    // Prefilter counters isolate row-id materialization. The generic scan 
counts below come
+    // from Lance's FilteredRead execution node and are scan inputs, not ANN 
result counts.
+    _lance_count_metrics = {
+            {"prefilter_loads",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterLoads", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"prefilter_input_rows",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterInputRows", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"prefilter_input_batches",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterInputBatches",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"prefilter_row_ids",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterRowIds", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"fragments_scanned",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceFragmentsScanned", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"ranges_scanned",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceRowOffsetRangesScanned",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"rows_scanned", ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceRowsScanned",
+                                                          TUnit::UNIT, 
LANCE_READER_PROFILE, 1)},
+            {"partitions_ranked",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIVFPartitionsRanked", TUnit::UNIT,
+                                          LANCE_READER_PROFILE, 1)},
+            {"partitions_searched",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceIVFPartitionsSearched",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"deltas_searched",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceVectorIndexSegmentsSearched",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"scalar_segments_requested",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceScalarIndexSegmentsRequested",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"scalar_segments_searched",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceScalarIndexSegmentsSearched",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"scalar_segment_fallbacks",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceScalarIndexSegmentFallbacks",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+            {"scalar_segment_candidate_rows",
+             ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LanceScalarIndexCandidateRows",
+                                          TUnit::UNIT, LANCE_READER_PROFILE, 
1)},
+    };
+    _lance_time_metrics = {
+            // Partition stages accumulate across concurrent work and overlap 
their parent timers.
+            // DistanceTopK includes fused candidate filtering, scoring, and 
heap updates.
+            {"index_open_time", ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexOpenTime",
+                                                           
LANCE_READER_PROFILE, 1)},
+            {"index_partition_load_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexPartitionLoadTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_partition_prepare_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexPartitionPrepareTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_prefilter_wait_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexPrefilterWaitTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_cpu_queue_wait_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexCpuQueueWaitTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_search_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexSearchTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_query_prepare_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexQueryPrepareTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_distance_topk_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexDistanceTopKTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"index_result_materialize_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIndexResultMaterializeTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"ANNIVFPartitionExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceANNPartitionExecTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"ANNSubIndexExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceANNSubIndexExecTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"ANNIvfBatchExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceANNBatchExecTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"SortExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceSortComputeTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"SortPreservingMergeExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceSortMergeComputeTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"TakeExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, "LanceTakeExecTime", 
LANCE_READER_PROFILE,
+                                        1)},
+            {"KNNVectorDistanceExec_elapsed_compute",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceVectorDistanceComputeTime",
+                                        LANCE_READER_PROFILE, 1)},
+            // These are wall times in the ANN row-id loader. LoadTime 
includes input polling
+            // and set construction; it must not be added to its component 
timers.
+            {"prefilter_load_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterLoadTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"prefilter_input_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterInputTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"prefilter_build_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LancePrefilterBuildTime",
+                                        LANCE_READER_PROFILE, 1)},
+
+            // This is wait time reported by the same Lance scan execution 
node described above,
+            // rather than Doris scanner scheduling wait time.
+            {"task_wait_time", ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceTaskWaitTime",
+                                                          
LANCE_READER_PROFILE, 1)},
+            {"find_partitions_elapsed",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceIVFPartitionRankingTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"scalar_segment_prepare_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceScalarIndexSegmentPrepareTime",
+                                        LANCE_READER_PROFILE, 1)},
+            {"scalar_segment_search_time",
+             ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, 
"LanceScalarIndexSegmentSearchTime",
+                                        LANCE_READER_PROFILE, 1)},
+    };
+    if (_search_kind != SearchKind::NORMAL) {
+        _planned_index_segment_count =
+                ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePlannedIndexSegmentCount",
+                                             TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+        _planned_indexed_fragment_count =
+                ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, 
"LancePlannedIndexedFragmentCount",
+                                             TUnit::UNIT, 
LANCE_READER_PROFILE, 1);
+        _planned_flat_search_fragment_count = ADD_CHILD_COUNTER_WITH_LEVEL(
+                _scanner_profile, "LancePlannedFlatSearchFragmentCount", 
TUnit::UNIT,
+                LANCE_READER_PROFILE, 1);
+    }
+}
+
+Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) {
+    _init_scanner_profile();
+    SCOPED_TIMER(_scanner_configure_time);
+    std::vector<const char*> columns;
+    columns.reserve(_projected_columns.size() + 1);
+    for (size_t idx = 0; idx < _projected_columns.size(); ++idx) {
+        if (_record_batch_converter.is_global_rowid_output(idx)) {
+            continue;
+        }
+        const auto& column = _projected_columns[idx];
+        columns.emplace_back(column.name.c_str());
+    }
+    if (_search_kind != SearchKind::NORMAL && columns.empty()) {
+        // Keep an explicit empty user projection from becoming `nullptr`, 
which means all dataset
+        // columns to lance-c. Search execution already returns its generated 
result column.
+        columns.emplace_back(_search_kind == SearchKind::VECTOR ? 
LANCE_DISTANCE_COLUMN.data()
+                                                                : 
LANCE_SCORE_COLUMN.data());
+    }
+    columns.emplace_back(nullptr);
+
+    const auto& lance_scan_params = _scan_params->lance_scan_params;
+    std::string sql_filter;
+    std::shared_ptr<const LanceRuntimeFilterSql> runtime_filter_sql;
+    if (_search_kind == SearchKind::NORMAL) {
+        if (has_lance_runtime_filters(_conjuncts)) {
+            if (_dataset_schema == nullptr) {
+                RETURN_IF_ERROR(import_lance_dataset_schema(_dataset, 
&_dataset_schema));
+            }
+            SCOPED_TIMER(_runtime_filter_sql_time);
+            runtime_filter_sql = get_or_create_lance_runtime_filter_sql(
+                    _conjuncts, *_dataset_schema, _runtime_filter_cache);
+        }
+    } else {
+        const auto& request = lance_scan_params.external_search_request;
+        if (request.__isset.search_filter &&
+            request.search_filter.format == TSearchFilterFormat::SQL) {
+            sql_filter = request.search_filter.payload;
+        }
+    }
+    LanceScanner* scanner = lance_scanner_new(_dataset, columns.data(),
+                                              sql_filter.empty() ? nullptr : 
sql_filter.c_str());
+    if (scanner == nullptr) {
+        return lance_error("create Lance scanner");
+    }
+    std::unique_ptr<LanceScanner, LanceScannerDeleter> scanner_guard(scanner);
+    const auto collect_scan_statistics = [](void* callback_ctx,
+                                            const LanceScanStatistics* 
statistics) {
+        LanceTableReader::_collect_scan_statistics(callback_ctx, statistics);
+    };
+    if (lance_scanner_set_statistics_callback(scanner, 
collect_scan_statistics, this) != 0) {
+        return lance_error("set Lance scanner statistics callback");
+    }
+
+    if (_record_batch_converter.requires_global_rowid() &&
+        lance_scanner_with_row_id(scanner, true) != 0) {
+        return lance_error("enable Lance row id output");
+    }
+
+    if (lance_scan_params.__isset.lance_substrait_filter &&
+        lance_scanner_set_substrait_filter(
+                scanner,
+                reinterpret_cast<const 
uint8_t*>(lance_scan_params.lance_substrait_filter.data()),
+                lance_scan_params.lance_substrait_filter.size()) != 0) {
+        return lance_error("set Lance Substrait filter");
+    }
+    if (runtime_filter_sql != nullptr) {
+        if (!runtime_filter_sql->expression.empty()) {
+            if (lance_scanner_additional_sql_filter(scanner,
+                                                    
runtime_filter_sql->expression.c_str()) != 0) {
+                return lance_error("set Lance additional SQL filter");
+            }
+        }
+        record_lance_runtime_filter_pushdown(_scanner_profile, 
*runtime_filter_sql);
+    }
+
+    const auto batch_size = _batch_size > 0 ? _batch_size : 
_runtime_state->batch_size();
+    if (lance_scanner_set_batch_size(scanner, 
static_cast<int64_t>(batch_size)) != 0) {
+        return lance_error("set Lance scanner batch size");
+    }
+    RETURN_IF_ERROR(_configure_scan_options(scanner));
+
+    const auto& lance_params = range.table_format_params.lance_params;
+    switch (_search_kind) {
+    case SearchKind::NORMAL:
+        RETURN_IF_ERROR(_configure_normal_scan(scanner, lance_params));
+        break;
+    case SearchKind::VECTOR:
+        RETURN_IF_ERROR(_configure_vector_search(scanner, lance_params));
+        break;
+    case SearchKind::FULL_TEXT:
+        RETURN_IF_ERROR(_configure_full_text_search(scanner, lance_params));
+        break;
+    }
+    _scanner = scanner_guard.release();
+    _scanner_batch_size = batch_size;
+    return Status::OK();
+}
+
+Status LanceTableReader::_configure_scan_options(LanceScanner* scanner) const {
+    DORIS_CHECK(scanner != nullptr);
+    // Doris runs multiple scanners concurrently. Limit each scanner's 
read-ahead;
+    // the I/O budget does not cap its total memory usage.
+    const auto io_buffer_size = 
static_cast<uint64_t>(config::lance_io_buffer_size_bytes);
+    const auto batch_readahead = 
static_cast<size_t>(config::lance_batch_readahead);
+    const auto fragment_readahead = 
static_cast<size_t>(config::lance_fragment_readahead);
+    constexpr bool scan_in_order = false;
+
+    if (lance_scanner_set_io_buffer_size(scanner, io_buffer_size) != 0) {
+        return lance_error("set Lance scanner I/O buffer size");
+    }
+    if (lance_scanner_set_batch_readahead(scanner, batch_readahead) != 0) {
+        return lance_error("set Lance scanner batch readahead");
+    }
+    if (lance_scanner_set_fragment_readahead(scanner, fragment_readahead) != 
0) {
+        return lance_error("set Lance scanner fragment readahead");
+    }
+    // Storage order is not required; query ordering is enforced by Sort/TopN 
operators.
+    if (lance_scanner_set_scan_in_order(scanner, scan_in_order) != 0) {
+        return lance_error("set Lance scanner scan order");
+    }
+
+    return Status::OK();
+}
+
+Status LanceTableReader::_configure_normal_scan(LanceScanner* scanner,
+                                                const TLanceFileDesc& 
lance_params) const {
+    DORIS_CHECK(scanner != nullptr);
+    std::vector<uint64_t> fragment_ids;
+    RETURN_IF_ERROR(parse_fragment_ids(lance_params, &fragment_ids));
+    if (!fragment_ids.empty() &&
+        lance_scanner_set_fragment_ids(scanner, fragment_ids.data(), 
fragment_ids.size()) != 0) {
+        return lance_error("set Lance scanner fragment ids");
+    }
+    std::vector<uint8_t> segment_uuids;
+    size_t segment_count = 0;
+    RETURN_IF_ERROR(parse_index_segment_uuids(lance_params, &segment_uuids, 
&segment_count));
+    if (segment_count > 1) {
+        return Status::InvalidArgument("normal Lance scan accepts only one 
scalar index segment");
+    }
+    if (segment_count == 1) {
+        if (fragment_ids.empty() || !lance_params.__isset.version || 
lance_params.version <= 0) {
+            return Status::InvalidArgument(
+                    "Lance scalar index segment requires a fixed version and 
nonempty fragment "
+                    "ids");
+        }
+        if (lance_params.__isset.use_scalar_index && 
!lance_params.use_scalar_index) {
+            return Status::InvalidArgument(
+                    "Lance scalar index segment cannot be combined with 
use_scalar_index=false");
+        }
+        if (lance_scanner_set_scalar_index_segment(scanner, 
segment_uuids.data()) != 0) {
+            return lance_error("set Lance scanner scalar index segment");
+        }
+    } else if (lance_params.__isset.use_scalar_index &&
+               lance_scanner_set_use_scalar_index(scanner, 
lance_params.use_scalar_index) != 0) {
+        return lance_error("set Lance scanner scalar index usage");
+    }
+    // FE sets this only when every predicate has been pushed into Lance.
+    if (lance_params.__isset.limit && lance_params.limit > 0 &&
+        lance_scanner_set_limit(scanner, lance_params.limit) != 0) {
+        return lance_error("set Lance scanner limit");
+    }
+    return Status::OK();
+}
+
+Status LanceTableReader::_configure_vector_search(LanceScanner* scanner,
+                                                  const TLanceFileDesc& 
lance_params) const {
+    DORIS_CHECK(scanner != nullptr);
+    DORIS_CHECK(_scan_params != nullptr);
+    DORIS_CHECK(_scan_params->__isset.lance_scan_params);
+    std::vector<uint64_t> fragment_ids;
+    RETURN_IF_ERROR(parse_fragment_ids(lance_params, &fragment_ids));
+    if (!fragment_ids.empty() &&
+        lance_scanner_set_fragment_ids(scanner, fragment_ids.data(), 
fragment_ids.size()) != 0) {
+        return lance_error("set Lance vector scanner fragment ids");
+    }
+    std::vector<uint8_t> segment_uuids;
+    size_t segment_count = 0;
+    RETURN_IF_ERROR(parse_index_segment_uuids(lance_params, &segment_uuids, 
&segment_count));
+    if (segment_count > 0 &&
+        lance_scanner_set_index_segments(scanner, segment_uuids.data(), 
segment_count) != 0) {
+        return lance_error("set Lance vector scanner index segments");
+    }
+    // Fragment-scoped nearest queries require prefiltering before installing 
the query. The same
+    // path applies the TVF search filter, when present.
+    if (lance_scanner_set_prefilter(scanner, true) != 0) {

Review Comment:
   [P1] Preserve the 4.x search-filter timing flag
   
   The branch-4.1/4.2 FE [serializes `TExternalSearchRequest.prefilter=false` 
by 
default](https://github.com/apache/doris/blob/a7ae6f4611e40dcf5e5444ae1d87bb4f79e4e490/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LanceExternalSearchTableValuedFunction.java#L202-L216),
 but this head omits wire field 5 and unconditionally enables prefiltering 
here. With `top_k=1`, `use_index=false`, and `filter='keep = true'`, a nearest 
row with `keep=false` followed by a farther `keep=true` row should yield an 
empty postfilter result; this BE returns the farther row. The FE keeps that TVF 
filter only in the request, so a Doris residual cannot restore the requested 
behavior during an FE-4.x/BE-master upgrade. Retain field 5 with its 
established default and honor it when configuring the scanner.



-- 
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]

Reply via email to