From a77f92371a161c13ddb69cb3cfe052b0cf79ab50 Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Fri, 28 Aug 2026 13:53:26 +0800 Subject: [PATCH 1/7] [opt](lance) support rf push down to lance scan --- .../format_v2/lance/lance_reader_helper.cpp | 279 + be/src/format_v2/lance/lance_reader_helper.h | 73 + .../lance/lance_runtime_filter_helper.cpp | 363 ++ .../lance/lance_runtime_filter_helper.h | 50 + be/src/format_v2/table/lance_reader.cpp | 378 +- be/src/format_v2/table/lance_reader.h | 16 +- .../lance_runtime_filter_helper_test.cpp | 200 + be/test/format_v2/table/lance_reader_test.cpp | 107 +- .../test_lance_runtime_filter_pushdown.out | 18 + .../test_lance_runtime_filter_pushdown.groovy | 158 + thirdparty/download-thirdparty.sh | 7 +- .../lance-c-0.1.7-runtime-filter.patch | 4904 +++++++++++++++++ 12 files changed, 6222 insertions(+), 331 deletions(-) create mode 100644 be/src/format_v2/lance/lance_reader_helper.cpp create mode 100644 be/src/format_v2/lance/lance_reader_helper.h create mode 100644 be/src/format_v2/lance/lance_runtime_filter_helper.cpp create mode 100644 be/src/format_v2/lance/lance_runtime_filter_helper.h create mode 100644 be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp create mode 100644 regression-test/data/external_table_p0/lance/test_lance_runtime_filter_pushdown.out create mode 100644 regression-test/suites/external_table_p0/lance/test_lance_runtime_filter_pushdown.groovy create mode 100644 thirdparty/patches/lance-c-0.1.7-runtime-filter.patch diff --git a/be/src/format_v2/lance/lance_reader_helper.cpp b/be/src/format_v2/lance/lance_reader_helper.cpp new file mode 100644 index 00000000000000..5d8013c102f6be --- /dev/null +++ b/be/src/format_v2/lance/lance_reader_helper.cpp @@ -0,0 +1,279 @@ +// 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 +#include +#include +#include + +#include +#include + +#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" + +namespace doris::format::lance { +namespace { + +constexpr std::string_view ARROW_EXTENSION_NAME = "ARROW:extension:name"; + +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; +} + +Status check_arrow_field_semantics(const std::shared_ptr& field) { + if (field->HasMetadata()) { + const auto extension_name = field->metadata()->Get(ARROW_EXTENSION_NAME); + if (extension_name.ok() && !extension_name.ValueUnsafe().empty()) { + return Status::NotSupported( + "unsupported Lance Arrow extension type '{}' for field '{}'", + extension_name.ValueUnsafe(), field->name()); + } + } + if (field->type()->id() == arrow::Type::DICTIONARY) { + return Status::NotSupported("unsupported Lance Arrow dictionary type for field '{}': {}", + field->name(), field->type()->ToString()); + } + return Status::OK(); +} + +Status arrow_field_to_doris_type(const std::shared_ptr& field, + DataTypePtr* doris_type) { + RETURN_IF_ERROR(check_arrow_field_semantics(field)); + const auto& arrow_type = field->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(); + }; + + switch (arrow_type->id()) { + 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::max()); + case arrow::Type::FIXED_SIZE_BINARY: { + const auto binary = std::static_pointer_cast(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_type); + return nullable_primitive(TYPE_TIMEV2, 0, arrow_time_precision(time->unit())); + } + case arrow::Type::TIMESTAMP: { + const auto timestamp = std::static_pointer_cast(arrow_type); + const auto doris_type = timestamp->timezone().empty() ? TYPE_DATETIMEV2 : TYPE_TIMESTAMPTZ; + return nullable_primitive(doris_type, 0, arrow_time_precision(timestamp->unit())); + } + case arrow::Type::DECIMAL128: + case arrow::Type::DECIMAL256: { + const auto decimal = std::static_pointer_cast(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; + return nullable_primitive(doris_decimal_type, precision, scale); + } + case arrow::Type::LIST: + case arrow::Type::LARGE_LIST: + case arrow::Type::FIXED_SIZE_LIST: { + const auto list = std::static_pointer_cast(arrow_type); + DataTypePtr value_type; + RETURN_IF_ERROR(arrow_field_to_doris_type(list->value_field(), &value_type)); + *doris_type = make_nullable(std::make_shared(value_type)); + return Status::OK(); + } + case arrow::Type::MAP: { + const auto map = std::static_pointer_cast(arrow_type); + RETURN_IF_ERROR(check_arrow_field_semantics(map->value_field())); + DataTypePtr key_type; + DataTypePtr item_type; + RETURN_IF_ERROR(arrow_field_to_doris_type(map->key_field(), &key_type)); + RETURN_IF_ERROR(arrow_field_to_doris_type(map->item_field(), &item_type)); + *doris_type = make_nullable(std::make_shared(key_type, item_type)); + return Status::OK(); + } + case arrow::Type::STRUCT: { + const auto struct_type = std::static_pointer_cast(arrow_type); + DataTypes field_types; + Strings field_names; + field_types.reserve(struct_type->num_fields()); + field_names.reserve(struct_type->num_fields()); + for (const auto& child : struct_type->fields()) { + DataTypePtr field_type; + RETURN_IF_ERROR(arrow_field_to_doris_type(child, &field_type)); + field_types.emplace_back(std::move(field_type)); + field_names.emplace_back(child->name()); + } + *doris_type = make_nullable(std::make_shared(field_types, field_names)); + return Status::OK(); + } + default: + return Status::NotSupported("unsupported Lance Arrow type: {}", arrow_type->ToString()); + } +} + +} // namespace + +void LanceDatasetDeleter::operator()(LanceDataset* dataset) const { + lance_dataset_close(dataset); +} + +void LanceScannerDeleter::operator()(LanceScanner* scanner) const { + lance_scanner_close(scanner); +} + +void LanceBatchDeleter::operator()(LanceBatch* batch) const { + lance_batch_free(batch); +} + +size_t lance_vector_element_width(TVectorElementType::type type) { + switch (type) { + case TVectorElementType::FLOAT16: + return sizeof(uint16_t); + case TVectorElementType::FLOAT32: + return sizeof(float); + case TVectorElementType::FLOAT64: + return sizeof(double); + case TVectorElementType::UINT8: + case TVectorElementType::INT8: + return sizeof(uint8_t); + } + return 0; +} + +Status convert_arrow_schema_to_doris(const std::shared_ptr& arrow_schema, + std::vector* column_names, + std::vector* column_types) { + DORIS_CHECK(arrow_schema != nullptr); + DORIS_CHECK(column_names != nullptr); + DORIS_CHECK(column_types != nullptr); + + std::vector parsed_names; + std::vector parsed_types; + parsed_names.reserve(arrow_schema->num_fields()); + parsed_types.reserve(arrow_schema->num_fields()); + std::unordered_set unique_names; + unique_names.reserve(arrow_schema->num_fields()); + for (const auto& field : arrow_schema->fields()) { + if (!unique_names.emplace(field->name()).second) { + return Status::InvalidArgument("duplicate Lance schema column: {}", field->name()); + } + DataTypePtr doris_type; + const auto type_status = arrow_field_to_doris_type(field, &doris_type); + if (type_status.is()) { + parsed_types.emplace_back(std::make_shared()); + } else { + RETURN_IF_ERROR(type_status); + DORIS_CHECK(doris_type != nullptr); + parsed_types.emplace_back(std::move(doris_type)); + } + parsed_names.emplace_back(field->name()); + } + *column_names = std::move(parsed_names); + *column_types = std::move(parsed_types); + return Status::OK(); +} + +Status build_lance_storage_options(const TFileScanRangeParams* scan_params, + std::vector* options) { + DORIS_CHECK(options != nullptr); + options->clear(); + if (scan_params == nullptr || !scan_params->__isset.lance_scan_params || + !scan_params->lance_scan_params.__isset.lance_storage_options) { + return Status::OK(); + } + const auto& storage_options = scan_params->lance_scan_params.lance_storage_options; + options->reserve(storage_options.size() * 2); + for (const auto& [key, value] : storage_options) { + // Both values cross a C-string boundary. Reject embedded NULs instead of silently opening + // a different dataset configuration from the one validated and used by the FE. + if (key.find('\0') != std::string::npos || value.find('\0') != std::string::npos) { + return Status::InvalidArgument( + "Lance storage option '{}' contains a NUL and cannot reach lance-c", + key.substr(0, key.find('\0'))); + } + options->emplace_back(key); + options->emplace_back(value); + } + return Status::OK(); +} + +Status lance_error(std::string_view operation) { + const char* raw_message = lance_last_error_message(); + std::string message = raw_message == nullptr ? "" : raw_message; + if (raw_message != nullptr) { + lance_free_string(raw_message); + } + if (message.empty()) { + message = fmt::format("error_code={}", static_cast(lance_last_error_code())); + } + return Status::InternalError("{} failed: {}", operation, message); +} + +} // namespace doris::format::lance diff --git a/be/src/format_v2/lance/lance_reader_helper.h b/be/src/format_v2/lance/lance_reader_helper.h new file mode 100644 index 00000000000000..15cf8ee61844d4 --- /dev/null +++ b/be/src/format_v2/lance/lance_reader_helper.h @@ -0,0 +1,73 @@ +// 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. + +#pragma once + +#include +#include +#include +#include +#include + +#include "common/status.h" +#include "core/data_type/data_type.h" +#include "gen_cpp/PlanNodes_types.h" + +struct LanceBatch; +struct LanceDataset; +struct LanceScanner; + +namespace arrow { +class Schema; +} // namespace arrow + +namespace doris::format::lance { + +inline constexpr std::string_view LANCE_DISTANCE_COLUMN = "_distance"; +inline constexpr std::string_view LANCE_ROW_ID_COLUMN = "_rowid"; +inline constexpr const char* LANCE_READER_PROFILE = "LanceReader"; + +struct LanceDatasetDeleter { + void operator()(LanceDataset* dataset) const; +}; + +struct LanceScannerDeleter { + void operator()(LanceScanner* scanner) const; +}; + +struct LanceBatchDeleter { + void operator()(LanceBatch* batch) const; +}; + +size_t lance_vector_element_width(TVectorElementType::type type); + +// Convert every top-level field without discarding unsupported columns. Malformed schemas still +// return an error and leave both output vectors unchanged. DataTypeNothing is the local sentinel +// for a valid Arrow field whose logical type Doris does not support. +Status convert_arrow_schema_to_doris(const std::shared_ptr& arrow_schema, + std::vector* column_names, + std::vector* column_types); + +// The FE sends storage options in Lance's own vocabulary. Preserve the key-value sequence exactly +// while validating that every value can cross the C-string boundary into lance-c. +Status build_lance_storage_options(const TFileScanRangeParams* scan_params, + std::vector* options); + +// Copy and release lance-c's thread-local error message before returning a Doris status. +Status lance_error(std::string_view operation); + +} // namespace doris::format::lance diff --git a/be/src/format_v2/lance/lance_runtime_filter_helper.cpp b/be/src/format_v2/lance/lance_runtime_filter_helper.cpp new file mode 100644 index 00000000000000..26b7c6402e78c7 --- /dev/null +++ b/be/src/format_v2/lance/lance_runtime_filter_helper.cpp @@ -0,0 +1,363 @@ +// 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 + +#include +#include +#include +#include +#include + +#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" + +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& 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(conjunct->root().get()); +} + +void append_sql_conjunct(std::string_view conjunct, std::string* expression) { + if (!expression->empty()) { + expression->append(" AND "); + } + expression->append(conjunct); +} + +std::string lowercase_ascii(std::string value) { + std::ranges::transform(value, value.begin(), [](const unsigned char ch) { + return static_cast(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::optional to_lance_sql_literal(const VLiteral& literal) { + 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); + 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"; + } + 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) { + 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 +std::optional in_value_to_lance_sql_literal(const void* raw_value, + const DataTypePtr& data_type) { + if (raw_value == nullptr || data_type == nullptr) { + return std::nullopt; + } + Field field; + if constexpr (is_string_type(PT)) { + const auto* value = static_cast(raw_value); + using CppType = typename PrimitiveTypeTraits::CppType; + field = Field::create_field(CppType(value->data, value->size)); + } else { + using CppType = typename PrimitiveTypeTraits::CppType; + field = Field::create_field(*static_cast(raw_value)); + } + return to_lance_sql_literal(VLiteral(data_type, field)); +} + +std::optional in_value_to_lance_sql_literal(PrimitiveType primitive_type, + const void* raw_value, + const DataTypePtr& data_type) { +#define DISPATCH_IN_VALUE(TYPE) \ + case TYPE: \ + return in_value_to_lance_sql_literal(raw_value, data_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 build_in_filter_sql(const VDirectInPredicate& predicate) { + if (predicate.get_num_children() != 1) { + return std::nullopt; + } + const auto slot = std::dynamic_pointer_cast(predicate.get_child(0)); + const auto values = predicate.get_set_func(); + if (slot == nullptr || slot->data_type() == nullptr || values == nullptr || + values->contain_null() || values->size() == 0) { + return std::nullopt; + } + + const auto data_type = remove_nullable(slot->data_type()); + 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); + if (!value.has_value()) { + return std::nullopt; + } + if (!first_value) { + expression.append(", "); + } + expression.append(*value); + first_value = false; + iterator->next(); + } + if (first_value) { + return std::nullopt; + } + expression.append("))"); + return expression; +} + +std::optional build_range_filter_sql(const VExpr& predicate) { + if ((predicate.op() != TExprOpcode::GE && predicate.op() != TExprOpcode::LE) || + predicate.get_num_children() != 2) { + return std::nullopt; + } + const auto slot = std::dynamic_pointer_cast(predicate.get_child(0)); + const auto literal = std::dynamic_pointer_cast(predicate.get_child(1)); + if (slot == nullptr || literal == nullptr) { + return std::nullopt; + } + const auto sql_literal = to_lance_sql_literal(*literal); + if (!sql_literal.has_value()) { + return std::nullopt; + } + const auto* sql_operator = predicate.op() == TExprOpcode::GE ? ">=" : "<="; + return "(" + quote_sql_identifier(slot->column_name()) + " " + sql_operator + " " + + *sql_literal + ")"; +} + +std::optional runtime_filter_to_lance_sql(const RuntimeFilterExpr& runtime_filter) { + const auto impl = runtime_filter.get_impl(); + if (impl == nullptr) { + return std::nullopt; + } + if (const auto* in_predicate = dynamic_cast(impl.get()); + in_predicate != nullptr) { + return build_in_filter_sql(*in_predicate); + } + return build_range_filter_sql(*impl); +} + +std::shared_ptr build_runtime_filter_sql( + const VExprContextSPtrs& conjuncts) { + auto result = std::make_shared(); + std::set seen_filter_ids; + std::set pushed_filter_ids; + for (const auto& conjunct : conjuncts) { + const auto* runtime_filter = get_runtime_filter(conjunct); + if (runtime_filter == nullptr) { + continue; + } + const auto filter_id = runtime_filter->filter_id(); + seen_filter_ids.emplace(filter_id); + + const auto expression = runtime_filter_to_lance_sql(*runtime_filter); + if (!expression.has_value()) { + continue; + } + append_sql_conjunct(*expression, &result->expression); + pushed_filter_ids.emplace(filter_id); + } + + result->pushable_filter_ids.assign(pushed_filter_ids.begin(), pushed_filter_ids.end()); + for (const auto filter_id : seen_filter_ids) { + if (!pushed_filter_ids.contains(filter_id)) { + result->skipped_filter_ids.emplace_back(filter_id); + } + } + return result; +} + +std::optional build_cache_key(const VExprContextSPtrs& conjuncts) { + // This cache is scoped to one FileScanLocalState. An RF is immutable after it is published, so + // the sorted RF IDs uniquely identify the snapshot shared by its parallel scanners. + std::set filter_ids; + for (const auto& conjunct : conjuncts) { + if (const auto* runtime_filter = get_runtime_filter(conjunct); runtime_filter != nullptr) { + filter_ids.emplace(runtime_filter->filter_id()); + } + } + if (filter_ids.empty()) { + return std::nullopt; + } + + std::string key(LANCE_RUNTIME_FILTER_CACHE_KEY_PREFIX); + for (const auto filter_id : filter_ids) { + key.append(std::to_string(filter_id)).append(","); + } + return key; +} + +} // namespace + +std::shared_ptr get_or_create_lance_runtime_filter_sql( + const VExprContextSPtrs& conjuncts, ShardedKVCache* cache) { + const auto cache_key = build_cache_key(conjuncts); + if (!cache_key.has_value()) { + return nullptr; + } + if (cache == nullptr) { + return build_runtime_filter_sql(conjuncts); + } + + auto* cached = cache->get>( + *cache_key, [&]() -> std::shared_ptr* { + return new std::shared_ptr( + build_runtime_filter_sql(conjuncts)); + }); + return cached == nullptr ? nullptr : *cached; +} + +void record_lance_runtime_filter_pushdown(RuntimeProfile* profile, + const LanceRuntimeFilterSql& runtime_filter_sql) { + DORIS_CHECK(profile != nullptr); + const auto pushed_ids = format_filter_ids(runtime_filter_sql.pushable_filter_ids); + const auto skipped_ids = format_filter_ids(runtime_filter_sql.skipped_filter_ids); + + if (!pushed_ids.empty()) { + profile->add_info_string("LanceRuntimeFilterPushedIds", pushed_ids); + } + if (!skipped_ids.empty()) { + profile->add_info_string("LanceRuntimeFilterSkippedIds", skipped_ids); + } + VLOG_DEBUG << "Lance runtime filter pushdown: pushed_ids=[" << pushed_ids << "], skipped_ids=[" + << skipped_ids << "]"; +} + +} // namespace doris::format::lance diff --git a/be/src/format_v2/lance/lance_runtime_filter_helper.h b/be/src/format_v2/lance/lance_runtime_filter_helper.h new file mode 100644 index 00000000000000..c40631bfdbcc09 --- /dev/null +++ b/be/src/format_v2/lance/lance_runtime_filter_helper.h @@ -0,0 +1,50 @@ +// 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. + +#pragma once + +#include +#include +#include + +#include "exprs/vexpr_fwd.h" + +namespace doris { + +class ShardedKVCache; +class RuntimeProfile; + +namespace format::lance { + +struct LanceRuntimeFilterSql { + std::string expression; + std::vector pushable_filter_ids; + std::vector skipped_filter_ids; +}; + +// Build one immutable SQL snapshot for all supported Doris runtime filters. When cache is non-null, +// equivalent RF snapshots from parallel Lance readers in the same FileScanLocalState share the +// conversion result. The returned snapshot also identifies RFs that cannot be represented exactly +// by Lance SQL. A null result means that conjuncts contain no Doris runtime filter. +std::shared_ptr get_or_create_lance_runtime_filter_sql( + const VExprContextSPtrs& conjuncts, ShardedKVCache* cache); + +void record_lance_runtime_filter_pushdown(RuntimeProfile* profile, + const LanceRuntimeFilterSql& runtime_filter_sql); + +} // namespace format::lance +} // namespace doris diff --git a/be/src/format_v2/table/lance_reader.cpp b/be/src/format_v2/table/lance_reader.cpp index e8ad0c5229aca3..f27b6dd9e3aa7e 100644 --- a/be/src/format_v2/table/lance_reader.cpp +++ b/be/src/format_v2/table/lance_reader.cpp @@ -21,236 +21,26 @@ #include #include #include -#include #include +#include #include #include #include #include +#include #include "common/consts.h" #include "common/logging.h" #include "core/column/column_nullable.h" #include "core/column/column_string.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_struct.h" #include "exec/common/endian.h" +#include "format_v2/lance/lance_reader_helper.h" +#include "format_v2/lance/lance_runtime_filter_helper.h" #include "runtime/file_scan_profile.h" #include "storage/utils.h" namespace doris::format::lance { -namespace { - -struct LanceDatasetDeleter { - void operator()(LanceDataset* dataset) const { lance_dataset_close(dataset); } -}; - -struct LanceScannerDeleter { - void operator()(LanceScanner* scanner) const { lance_scanner_close(scanner); } -}; - -struct LanceBatchDeleter { - void operator()(LanceBatch* batch) const { lance_batch_free(batch); } -}; - -constexpr std::string_view DISTANCE_COLUMN = "_distance"; -constexpr std::string_view ROW_ID_COLUMN = "_rowid"; -constexpr std::string_view ARROW_EXTENSION_NAME = "ARROW:extension:name"; -constexpr const char* LANCE_READER_PROFILE = "LanceReader"; - -size_t vector_element_width(TVectorElementType::type type) { - switch (type) { - case TVectorElementType::FLOAT16: - return sizeof(uint16_t); - case TVectorElementType::FLOAT32: - return sizeof(float); - case TVectorElementType::FLOAT64: - return sizeof(double); - case TVectorElementType::UINT8: - case TVectorElementType::INT8: - return sizeof(uint8_t); - } - return 0; -} - -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; -} - -Status check_arrow_field_semantics(const std::shared_ptr& field) { - if (field->HasMetadata()) { - const auto extension_name = field->metadata()->Get(ARROW_EXTENSION_NAME); - if (extension_name.ok() && !extension_name.ValueUnsafe().empty()) { - return Status::NotSupported( - "unsupported Lance Arrow extension type '{}' for field '{}'", - extension_name.ValueUnsafe(), field->name()); - } - } - if (field->type()->id() == arrow::Type::DICTIONARY) { - return Status::NotSupported("unsupported Lance Arrow dictionary type for field '{}': {}", - field->name(), field->type()->ToString()); - } - return Status::OK(); -} - -Status arrow_field_to_doris_type(const std::shared_ptr& field, - DataTypePtr* doris_type) { - RETURN_IF_ERROR(check_arrow_field_semantics(field)); - const auto& arrow_type = field->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(); - }; - - switch (arrow_type->id()) { - 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::max()); - case arrow::Type::FIXED_SIZE_BINARY: { - const auto binary = std::static_pointer_cast(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_type); - return nullable_primitive(TYPE_TIMEV2, 0, arrow_time_precision(time->unit())); - } - case arrow::Type::TIMESTAMP: { - const auto timestamp = std::static_pointer_cast(arrow_type); - const auto doris_type = timestamp->timezone().empty() ? TYPE_DATETIMEV2 : TYPE_TIMESTAMPTZ; - return nullable_primitive(doris_type, 0, arrow_time_precision(timestamp->unit())); - } - case arrow::Type::DECIMAL128: - case arrow::Type::DECIMAL256: { - const auto decimal = std::static_pointer_cast(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; - return nullable_primitive(doris_decimal_type, precision, scale); - } - case arrow::Type::LIST: - case arrow::Type::LARGE_LIST: - case arrow::Type::FIXED_SIZE_LIST: { - const auto list = std::static_pointer_cast(arrow_type); - DataTypePtr value_type; - RETURN_IF_ERROR(arrow_field_to_doris_type(list->value_field(), &value_type)); - *doris_type = make_nullable(std::make_shared(value_type)); - return Status::OK(); - } - case arrow::Type::MAP: { - const auto map = std::static_pointer_cast(arrow_type); - RETURN_IF_ERROR(check_arrow_field_semantics(map->value_field())); - DataTypePtr key_type; - DataTypePtr item_type; - RETURN_IF_ERROR(arrow_field_to_doris_type(map->key_field(), &key_type)); - RETURN_IF_ERROR(arrow_field_to_doris_type(map->item_field(), &item_type)); - *doris_type = make_nullable(std::make_shared(key_type, item_type)); - return Status::OK(); - } - case arrow::Type::STRUCT: { - const auto struct_type = std::static_pointer_cast(arrow_type); - DataTypes field_types; - Strings field_names; - field_types.reserve(struct_type->num_fields()); - field_names.reserve(struct_type->num_fields()); - for (const auto& child : struct_type->fields()) { - DataTypePtr field_type; - RETURN_IF_ERROR(arrow_field_to_doris_type(child, &field_type)); - field_types.emplace_back(std::move(field_type)); - field_names.emplace_back(child->name()); - } - *doris_type = make_nullable(std::make_shared(field_types, field_names)); - return Status::OK(); - } - default: - return Status::NotSupported("unsupported Lance Arrow type: {}", arrow_type->ToString()); - } -} - -} // namespace - -Status convert_arrow_schema_to_doris(const std::shared_ptr& arrow_schema, - std::vector* column_names, - std::vector* column_types) { - DORIS_CHECK(arrow_schema != nullptr); - DORIS_CHECK(column_names != nullptr); - DORIS_CHECK(column_types != nullptr); - - std::vector parsed_names; - std::vector parsed_types; - parsed_names.reserve(arrow_schema->num_fields()); - parsed_types.reserve(arrow_schema->num_fields()); - std::unordered_set unique_names; - unique_names.reserve(arrow_schema->num_fields()); - for (const auto& field : arrow_schema->fields()) { - if (!unique_names.emplace(field->name()).second) { - return Status::InvalidArgument("duplicate Lance schema column: {}", field->name()); - } - DataTypePtr doris_type; - const auto type_status = arrow_field_to_doris_type(field, &doris_type); - if (type_status.is()) { - parsed_types.emplace_back(std::make_shared()); - } else { - RETURN_IF_ERROR(type_status); - DORIS_CHECK(doris_type != nullptr); - parsed_types.emplace_back(std::move(doris_type)); - } - parsed_names.emplace_back(field->name()); - } - *column_names = std::move(parsed_names); - *column_types = std::move(parsed_types); - return Status::OK(); -} LanceTableReader::~LanceTableReader() { static_cast(close()); @@ -265,7 +55,7 @@ Status LanceTableReader::fetch_schema(const TFileRangeDesc& range, } const auto& params = range.table_format_params.lance_params; std::vector storage_options; - RETURN_IF_ERROR(_storage_options(&scan_params, &storage_options)); + RETURN_IF_ERROR(build_lance_storage_options(&scan_params, &storage_options)); std::vector storage_option_ptrs; storage_option_ptrs.reserve(storage_options.size() + 1); for (const auto& option : storage_options) { @@ -278,12 +68,12 @@ Status LanceTableReader::fetch_schema(const TFileRangeDesc& range, storage_options.empty() ? nullptr : storage_option_ptrs.data(), static_cast(params.version))); if (dataset == nullptr) { - return _lance_error("open Lance dataset for schema"); + return lance_error("open Lance dataset for schema"); } ArrowSchema arrow_schema {}; if (lance_dataset_schema(dataset.get(), &arrow_schema) != 0) { - return _lance_error("get Lance dataset schema"); + return lance_error("get Lance dataset schema"); } auto imported_schema = arrow::ImportSchema(&arrow_schema); if (!imported_schema.ok()) { @@ -414,12 +204,12 @@ Status LanceTableReader::init(TableReadOptions&& options) { if (!_output_name_to_idx.emplace(column.name, idx).second) { return Status::InvalidArgument("duplicate Lance projected column: {}", column.name); } - if (_vector_search && column.name == DISTANCE_COLUMN) { + if (_vector_search && column.name == LANCE_DISTANCE_COLUMN) { const auto distance_type = remove_nullable(column.type); if (distance_type->get_primitive_type() != TYPE_FLOAT) { return Status::InvalidArgument( "Lance vector search column '{}' must have Doris FLOAT type, but was {}", - DISTANCE_COLUMN, column.type->get_name()); + LANCE_DISTANCE_COLUMN, column.type->get_name()); } } } @@ -429,6 +219,7 @@ Status LanceTableReader::init(TableReadOptions&& options) { Status LanceTableReader::prepare_split(const SplitReadOptions& options) { _close_scanner(); _eof = false; + _runtime_filter_cache = options.cache; RETURN_IF_ERROR(TableReader::prepare_split(options)); // Lance does not currently provide metadata aggregate pushdown. Do not let a generic @@ -485,7 +276,7 @@ Status LanceTableReader::get_block(Block* block, bool* eos) { break; } if (scan_status != 0 || raw_batch == nullptr) { - return _lance_error("read next Lance batch"); + return lance_error("read next Lance batch"); } std::unique_ptr batch(raw_batch); @@ -552,7 +343,7 @@ Status LanceTableReader::read_by_row_ids(const TFileRangeDesc& range, if (stream.release != nullptr) { stream.release(&stream); } - return _lance_error("take Lance rows by row id"); + return lance_error("take Lance rows by row id"); } auto imported_reader = arrow::ImportRecordBatchReader(&stream); if (!imported_reader.ok()) { @@ -658,7 +449,7 @@ Status LanceTableReader::_validate_external_search_request() const { return Status::InvalidArgument("Lance query vector dimension must be positive: {}", query_vector.dimension); } - const auto element_width = vector_element_width(query_vector.element_type); + 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(query_vector.element_type)); @@ -687,19 +478,13 @@ Status LanceTableReader::_validate_external_search_request() const { return Status::InvalidArgument( "external search filter requires format and non-empty payload"); } - switch (filter.format) { - case TSearchFilterFormat::SQL: - if (filter.payload.find('\0') != std::string::npos) { - return Status::InvalidArgument( - "Lance SQL search filter contains an embedded NUL byte"); - } - break; - case TSearchFilterFormat::SUBSTRAIT: - break; - default: + if (filter.format != TSearchFilterFormat::SQL) { return Status::NotSupported("unsupported external search filter format: {}", static_cast(filter.format)); } + if (filter.payload.find('\0') != std::string::npos) { + return Status::InvalidArgument("Lance SQL search filter contains an embedded NUL byte"); + } } if (request.__isset.vector_search_options) { @@ -745,7 +530,7 @@ Status LanceTableReader::_open_dataset(const DatasetKey& key) { static_cast(key.version)); } if (_dataset == nullptr) { - return _lance_error("open Lance dataset"); + return lance_error("open Lance dataset"); } return Status::OK(); } @@ -764,23 +549,28 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { if (_vector_search && columns.empty()) { // Keep an explicit empty user projection from becoming `nullptr`, which means all dataset // columns to lance-c. nearest() already returns this optional system column. - columns.emplace_back(DISTANCE_COLUMN.data()); + columns.emplace_back(LANCE_DISTANCE_COLUMN.data()); } columns.emplace_back(nullptr); const auto& lance_scan_params = _scan_params->lance_scan_params; - const char* sql_filter = nullptr; + std::string sql_filter; + std::shared_ptr runtime_filter_sql; if (_vector_search) { 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.c_str(); + sql_filter = request.search_filter.payload; } + } else { + runtime_filter_sql = + get_or_create_lance_runtime_filter_sql(_conjuncts, _runtime_filter_cache); } LanceScanner* scanner = - lance_scanner_new(_dataset, columns.size() == 1 ? nullptr : columns.data(), sql_filter); + lance_scanner_new(_dataset, columns.size() == 1 ? nullptr : columns.data(), + sql_filter.empty() ? nullptr : sql_filter.c_str()); if (scanner == nullptr) { - return _lance_error("create Lance scanner"); + return lance_error("create Lance scanner"); } std::unique_ptr scanner_guard(scanner); const auto collect_scan_statistics = [](void* callback_ctx, @@ -788,38 +578,31 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { 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"); + return lance_error("set Lance scanner statistics callback"); } if (_global_rowid_output_idx.has_value() && lance_scanner_with_row_id(scanner, true) != 0) { - return _lance_error("enable Lance row id output"); + return lance_error("enable Lance row id output"); } - if (_scan_params->__isset.lance_scan_params && - lance_scan_params.__isset.lance_substrait_filter && - !lance_scan_params.lance_substrait_filter.empty()) { - const auto& filter = lance_scan_params.lance_substrait_filter; - if (lance_scanner_set_substrait_filter( - scanner, reinterpret_cast(filter.data()), filter.size()) != 0) { - return _lance_error("set Lance Substrait filter"); - } + if (lance_scan_params.__isset.lance_substrait_filter && + lance_scanner_set_substrait_filter( + scanner, + reinterpret_cast(lance_scan_params.lance_substrait_filter.data()), + lance_scan_params.lance_substrait_filter.size()) != 0) { + return lance_error("set Lance Substrait filter"); } - if (_vector_search) { - const auto& request = lance_scan_params.external_search_request; - if (request.__isset.search_filter && - request.search_filter.format == TSearchFilterFormat::SUBSTRAIT) { - const auto& filter = request.search_filter.payload; - if (lance_scanner_set_substrait_filter(scanner, - reinterpret_cast(filter.data()), - filter.size()) != 0) { - return _lance_error("set Lance vector search Substrait filter"); - } + if (runtime_filter_sql != nullptr && !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(batch_size)) != 0) { - return _lance_error("set Lance scanner batch size"); + return lance_error("set Lance scanner batch size"); } const auto& lance_params = range.table_format_params.lance_params; @@ -832,7 +615,7 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { } if (lance_scanner_set_fragment_ids(scanner, fragment_ids.data(), fragment_ids.size()) != 0) { - return _lance_error("set Lance scanner fragment ids"); + return lance_error("set Lance scanner fragment ids"); } } if (lance_params.__isset.index_segment_uuids && !lance_params.index_segment_uuids.empty()) { @@ -856,7 +639,7 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { } if (lance_scanner_set_index_segments(scanner, segment_uuids.data(), lance_params.index_segment_uuids.size()) != 0) { - return _lance_error("set Lance scanner index segments"); + return lance_error("set Lance scanner index segments"); } } // Ordinary scans may carry a pushed-down LIMIT. The FE only sets it when all predicates are @@ -864,7 +647,7 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { // its own top_k limit in _configure_vector_search, so skip it here. if (!_vector_search && lance_params.__isset.limit && lance_params.limit > 0) { if (lance_scanner_set_limit(scanner, lance_params.limit) != 0) { - return _lance_error("set Lance scanner limit"); + return lance_error("set Lance scanner limit"); } } if (_vector_search) { @@ -872,7 +655,7 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { // Tell Lance that this fragment scan is the input to nearest() before installing the // query. The same prefilter path also applies the TVF search filter, when present. if (lance_scanner_set_prefilter(scanner, true) != 0) { - return _lance_error("enable Lance vector prefilter"); + return lance_error("enable Lance vector prefilter"); } RETURN_IF_ERROR(_configure_vector_search(scanner)); const int64_t fragment_count = @@ -908,7 +691,7 @@ Status LanceTableReader::_configure_vector_search(LanceScanner* scanner) const { const auto set_nearest = [&](const void* values, LanceDataType type) -> Status { if (lance_scanner_nearest(scanner, vector.column.c_str(), values, dimension, type, candidate_k) != 0) { - return _lance_error("set Lance nearest query"); + return lance_error("set Lance nearest query"); } return Status::OK(); }; @@ -977,7 +760,7 @@ Status LanceTableReader::_configure_vector_search(LanceScanner* scanner) const { static_cast(vector.metric)); } if (lance_scanner_set_metric(scanner, metric) != 0) { - return _lance_error("set Lance vector metric"); + return lance_error("set Lance vector metric"); } } @@ -985,27 +768,27 @@ Status LanceTableReader::_configure_vector_search(LanceScanner* scanner) const { const auto& options = request.vector_search_options; if (options.__isset.nprobes && lance_scanner_set_nprobes(scanner, static_cast(options.nprobes)) != 0) { - return _lance_error("set Lance vector nprobes"); + return lance_error("set Lance vector nprobes"); } if (options.__isset.refine_factor && lance_scanner_set_refine_factor(scanner, static_cast(options.refine_factor)) != 0) { - return _lance_error("set Lance vector refine factor"); + return lance_error("set Lance vector refine factor"); } if (options.__isset.ef && lance_scanner_set_ef(scanner, static_cast(options.ef)) != 0) { - return _lance_error("set Lance vector ef"); + return lance_error("set Lance vector ef"); } if (options.__isset.use_index && lance_scanner_set_use_index(scanner, options.use_index) != 0) { - return _lance_error("set Lance vector use_index"); + return lance_error("set Lance vector use_index"); } } if (lance_scanner_set_offset(scanner, vector.offset) != 0) { - return _lance_error("set Lance vector offset"); + return lance_error("set Lance vector offset"); } if (lance_scanner_set_limit(scanner, vector.top_k) != 0) { - return _lance_error("set Lance vector result limit"); + return lance_error("set Lance vector result limit"); } return Status::OK(); } @@ -1109,7 +892,7 @@ Status LanceTableReader::_fill_block_from_lance_batch(LanceBatch* batch, Block* ArrowArray array {}; ArrowSchema schema {}; if (lance_batch_to_arrow(batch, &array, &schema) != 0) { - return _lance_error("export Lance batch to Arrow"); + return lance_error("export Lance batch to Arrow"); } auto result = arrow::ImportRecordBatch(&array, &schema); if (!result.ok()) { @@ -1176,11 +959,12 @@ Status LanceTableReader::_fill_block_from_record_batch( auto& columns = columns_guard.mutable_columns(); for (int arrow_idx = 0; arrow_idx < record_batch->num_columns(); ++arrow_idx) { const auto& field = record_batch->schema()->field(arrow_idx); - if (field->name() == ROW_ID_COLUMN && _global_rowid_output_idx.has_value()) { + if (field->name() == LANCE_ROW_ID_COLUMN && _global_rowid_output_idx.has_value()) { const auto output_idx = *_global_rowid_output_idx; const auto& output_name = _projected_columns[output_idx].name; if (!materialized_columns.emplace(output_name).second) { - return Status::InternalError("Lance returned duplicate column '{}'", ROW_ID_COLUMN); + return Status::InternalError("Lance returned duplicate column '{}'", + LANCE_ROW_ID_COLUMN); } RETURN_IF_ERROR( _append_global_row_ids(record_batch->column(arrow_idx), columns[output_idx])); @@ -1188,7 +972,7 @@ Status LanceTableReader::_fill_block_from_record_batch( } const auto output_it = _output_name_to_idx.find(field->name()); if (output_it == _output_name_to_idx.end()) { - if (_vector_search && field->name() == DISTANCE_COLUMN) { + if (_vector_search && field->name() == LANCE_DISTANCE_COLUMN) { // Lance currently auto-projects _distance for nearest queries. It is valid for // Doris slot pruning to omit that optional result column. continue; @@ -1219,53 +1003,11 @@ Status LanceTableReader::_fill_block_from_record_batch( return Status::OK(); } -// The FE sends these already in Lance's own vocabulary, merged from the catalog properties and -// from whatever the namespace vended. Re-encoding them here would drop every option this list did -// not anticipate, so they are handed to lance-c as they arrive. -Status LanceTableReader::_storage_options(const TFileScanRangeParams* scan_params, - std::vector* options) { - options->clear(); - if (scan_params == nullptr || !scan_params->__isset.lance_scan_params || - !scan_params->lance_scan_params.__isset.lance_storage_options) { - return Status::OK(); - } - const auto& storage_options = scan_params->lance_scan_params.lance_storage_options; - options->reserve(storage_options.size() * 2); - for (const auto& [key, value] : storage_options) { - // These become C strings below, so a NUL would truncate the option here while the FE went - // on using the whole thing, and the two halves would open the dataset with different - // configuration. The FE rejects these on both paths it builds options from - its own - // storage configuration and what a namespace vends - so this is the last line of defence, - // for an FE that predates those checks. Dropping one here instead of failing would just - // recreate the divergence it exists to prevent. - if (key.find('\0') != std::string::npos || value.find('\0') != std::string::npos) { - return Status::InvalidArgument( - "Lance storage option '{}' contains a NUL and cannot reach lance-c", - key.substr(0, key.find('\0'))); - } - options->emplace_back(key); - options->emplace_back(value); - } - return Status::OK(); -} - Status LanceTableReader::_dataset_key(const TFileRangeDesc& range, DatasetKey* key) const { const auto& params = range.table_format_params.lance_params; key->uri = params.dataset_uri; key->version = params.version; - return _storage_options(_scan_params, &key->storage_options); -} - -Status LanceTableReader::_lance_error(std::string_view operation) { - const char* raw_message = lance_last_error_message(); - std::string message = raw_message == nullptr ? "" : raw_message; - if (raw_message != nullptr) { - lance_free_string(raw_message); - } - if (message.empty()) { - message = fmt::format("error_code={}", static_cast(lance_last_error_code())); - } - return Status::InternalError("{} failed: {}", operation, message); + return build_lance_storage_options(_scan_params, &key->storage_options); } } // namespace doris::format::lance diff --git a/be/src/format_v2/table/lance_reader.h b/be/src/format_v2/table/lance_reader.h index 892aaf518e5d83..a554f19533c780 100644 --- a/be/src/format_v2/table/lance_reader.h +++ b/be/src/format_v2/table/lance_reader.h @@ -36,21 +36,17 @@ struct LanceBatch; struct LanceDataset; struct LanceScanner; +namespace doris { +class ShardedKVCache; +} + namespace arrow { class Array; class RecordBatch; -class Schema; } // namespace arrow namespace doris::format::lance { -// Convert every top-level field without discarding unsupported columns. Malformed schemas still -// return an error and leave both output vectors unchanged. DataTypeNothing is the local sentinel -// for a valid Arrow field whose logical type Doris does not support. -Status convert_arrow_schema_to_doris(const std::shared_ptr& arrow_schema, - std::vector* column_names, - std::vector* column_types); - // A FORMAT_LANCE table reader. Unlike file formats such as Parquet, a Lance split is not a // physical-file range. It either selects fragments from a fixed snapshot or scans the whole // latest snapshot, so the dataset is owned by this table reader and each split owns its scanner. @@ -98,13 +94,11 @@ class LanceTableReader final : public TableReader { Block* block, size_t* rows); Status _append_global_row_ids(const std::shared_ptr& row_ids, MutableColumnPtr& output_column) const; - static Status _storage_options(const TFileScanRangeParams* scan_params, - std::vector* options); Status _dataset_key(const TFileRangeDesc& range, DatasetKey* key) const; - static Status _lance_error(std::string_view operation); LanceDataset* _dataset = nullptr; LanceScanner* _scanner = nullptr; + ShardedKVCache* _runtime_filter_cache = nullptr; std::optional _opened_dataset_key; std::unordered_map _output_name_to_idx; std::optional _global_rowid_output_idx; diff --git a/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp b/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp new file mode 100644 index 00000000000000..af65092f83e3ba --- /dev/null +++ b/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp @@ -0,0 +1,200 @@ +// 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 + +#include +#include +#include +#include +#include + +#include "core/data_type/data_type_nullable.h" +#include "core/data_type/data_type_number.h" +#include "core/data_type/data_type_string.h" +#include "core/field.h" +#include "exprs/hybrid_set.h" +#include "exprs/runtime_filter_expr.h" +#include "exprs/vbloom_predicate.h" +#include "exprs/vdirect_in_predicate.h" +#include "exprs/vectorized_fn_call.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" + +namespace doris::format::lance { +namespace { + +TExprNode runtime_in_node() { + TExprNode node; + node.__set_type(std::make_shared()->to_thrift()); + node.__set_node_type(TExprNodeType::IN_PRED); + node.in_predicate.__set_is_not_in(false); + node.__set_opcode(TExprOpcode::FILTER_IN); + node.__set_is_nullable(false); + return node; +} + +VExprContextSPtr wrap_runtime_filter(VExprSPtr impl, const TExprNode& node, int filter_id) { + return VExprContext::create_shared( + RuntimeFilterExpr::create_shared(node, std::move(impl), 0.0, false, filter_id)); +} + +VExprContextSPtr int64_runtime_in(std::string column_name, std::vector values, + int filter_id) { + std::shared_ptr filter(create_set(TYPE_BIGINT, false)); + for (const auto value : values) { + filter->insert(&value); + } + auto node = runtime_in_node(); + auto predicate = VDirectInPredicate::create_shared(node, std::move(filter), true); + predicate->add_child(VSlotRef::create_shared( + 0, 0, -1, make_nullable(std::make_shared()), std::move(column_name))); + return wrap_runtime_filter(std::move(predicate), node, filter_id); +} + +VExprContextSPtr string_runtime_in(std::string column_name, const std::string& value, + int filter_id) { + std::shared_ptr filter(create_set(TYPE_STRING, false)); + StringRef value_ref(value.data(), value.size()); + filter->insert(&value_ref); + auto node = runtime_in_node(); + auto predicate = VDirectInPredicate::create_shared(node, std::move(filter), true); + predicate->add_child(VSlotRef::create_shared( + 0, 0, -1, make_nullable(std::make_shared()), std::move(column_name))); + return wrap_runtime_filter(std::move(predicate), node, filter_id); +} + +VExprContextSPtr int64_runtime_range(std::string column_name, TExprOpcode::type opcode, + int64_t value, int filter_id) { + const auto value_type = std::make_shared(); + const auto nullable_value_type = make_nullable(value_type); + const auto result_type = make_nullable(std::make_shared()); + + TFunctionName function_name; + function_name.__set_function_name(opcode == TExprOpcode::GE ? "ge" : "le"); + TFunction function; + function.__set_name(function_name); + function.__set_binary_type(TFunctionBinaryType::BUILTIN); + function.__set_arg_types({nullable_value_type->to_thrift(), value_type->to_thrift()}); + function.__set_ret_type(result_type->to_thrift()); + function.__set_has_var_args(false); + + TExprNode predicate_node; + predicate_node.__set_node_type(TExprNodeType::BINARY_PRED); + predicate_node.__set_opcode(opcode); + predicate_node.__set_type(result_type->to_thrift()); + predicate_node.__set_fn(function); + predicate_node.__set_num_children(2); + predicate_node.__set_is_nullable(true); + auto predicate = VectorizedFnCall::create_shared(predicate_node); + predicate->add_child( + VSlotRef::create_shared(0, 0, -1, nullable_value_type, std::move(column_name))); + predicate->add_child( + VLiteral::create_shared(value_type, Field::create_field(value))); + + TExprNode wrapper_node; + wrapper_node.__set_type(std::make_shared()->to_thrift()); + wrapper_node.__set_is_nullable(false); + return wrap_runtime_filter(std::move(predicate), wrapper_node, filter_id); +} + +VExprContextSPtr unsupported_bloom_runtime_filter(std::string column_name, int filter_id) { + auto node = runtime_in_node(); + node.__set_node_type(TExprNodeType::BLOOM_PRED); + node.__set_opcode(TExprOpcode::RT_FILTER); + auto predicate = VBloomPredicate::create_shared(node); + predicate->add_child(VSlotRef::create_shared( + 0, 0, -1, make_nullable(std::make_shared()), std::move(column_name))); + return wrap_runtime_filter(std::move(predicate), node, filter_id); +} + +TEST(LanceRuntimeFilterHelperTest, ConvertsSupportedFiltersToLanceSql) { + const VExprContextSPtrs conjuncts { + int64_runtime_in("order`key", {7}, 3), + string_runtime_in("author", "O'Reilly", 5), + int64_runtime_range("score", TExprOpcode::GE, 10, 7), + int64_runtime_range("score", TExprOpcode::LE, 20, 7), + }; + + const auto result = get_or_create_lance_runtime_filter_sql(conjuncts, nullptr); + ASSERT_NE(result, nullptr); + EXPECT_EQ( + "(`order``key` IN (7)) AND (`author` IN ('O''Reilly')) AND " + "(`score` >= 10) AND (`score` <= 20)", + result->expression); + EXPECT_EQ((std::vector {3, 5, 7}), result->pushable_filter_ids); + EXPECT_TRUE(result->skipped_filter_ids.empty()); +} + +TEST(LanceRuntimeFilterHelperTest, RecordsUnsupportedRuntimeFilters) { + const VExprContextSPtrs conjuncts { + int64_runtime_in("id", {2}, 3), + unsupported_bloom_runtime_filter("id", 8), + }; + + const auto result = get_or_create_lance_runtime_filter_sql(conjuncts, nullptr); + ASSERT_NE(result, nullptr); + EXPECT_EQ("(`id` IN (2))", result->expression); + EXPECT_EQ((std::vector {3}), result->pushable_filter_ids); + EXPECT_EQ((std::vector {8}), result->skipped_filter_ids); + + RuntimeProfile profile("lance_runtime_filter_profile"); + record_lance_runtime_filter_pushdown(&profile, *result); + ASSERT_NE(profile.get_info_string("LanceRuntimeFilterPushedIds"), nullptr); + EXPECT_EQ("3", *profile.get_info_string("LanceRuntimeFilterPushedIds")); + ASSERT_NE(profile.get_info_string("LanceRuntimeFilterSkippedIds"), nullptr); + EXPECT_EQ("8", *profile.get_info_string("LanceRuntimeFilterSkippedIds")); +} + +TEST(LanceRuntimeFilterHelperTest, IgnoresNonRuntimeFilterConjuncts) { + const VExprContextSPtrs conjuncts {VExprContext::create_shared(VSlotRef::create_shared( + 0, 0, -1, std::make_shared(), "ordinary_column"))}; + + EXPECT_EQ(nullptr, get_or_create_lance_runtime_filter_sql(conjuncts, nullptr)); +} + +TEST(LanceRuntimeFilterHelperTest, ReusesSnapshotAcrossParallelReaders) { + ShardedKVCache cache(2); + const VExprContextSPtrs first_conjuncts { + int64_runtime_in("id", {2}, 12), + int64_runtime_range("score", TExprOpcode::GE, 10, 13), + }; + const VExprContextSPtrs reordered_conjuncts { + int64_runtime_range("score", TExprOpcode::GE, 10, 13), + int64_runtime_in("id", {2}, 12), + }; + + const auto first = get_or_create_lance_runtime_filter_sql(first_conjuncts, &cache); + const auto reused = get_or_create_lance_runtime_filter_sql(reordered_conjuncts, &cache); + ASSERT_NE(first, nullptr); + ASSERT_NE(reused, nullptr); + EXPECT_EQ(first.get(), reused.get()); + EXPECT_EQ("(`id` IN (2)) AND (`score` >= 10)", reused->expression); + + const auto different = + get_or_create_lance_runtime_filter_sql({int64_runtime_in("id", {2}, 14)}, &cache); + ASSERT_NE(different, nullptr); + EXPECT_NE(first.get(), different.get()); +} + +} // namespace +} // namespace doris::format::lance diff --git a/be/test/format_v2/table/lance_reader_test.cpp b/be/test/format_v2/table/lance_reader_test.cpp index 9a2e802217eeb4..4a6335422bc7ec 100644 --- a/be/test/format_v2/table/lance_reader_test.cpp +++ b/be/test/format_v2/table/lance_reader_test.cpp @@ -57,7 +57,13 @@ #include "core/data_type/data_type_number.h" #include "core/data_type/data_type_varbinary.h" #include "exec/common/endian.h" +#include "exprs/hybrid_set.h" +#include "exprs/runtime_filter_expr.h" +#include "exprs/vdirect_in_predicate.h" #include "exprs/vexpr.h" +#include "exprs/vexpr_context.h" +#include "exprs/vslot_ref.h" +#include "format_v2/lance/lance_reader_helper.h" #include "runtime/runtime_profile.h" #include "runtime/runtime_state.h" #include "storage/utils.h" @@ -166,6 +172,26 @@ ColumnDefinition projected_column(std::string name, PrimitiveType type, bool nul DataTypeFactory::instance().create_data_type(type, nullable)); } +VExprContextSPtr create_int64_runtime_in_conjunct(std::string column_name, + const std::vector& values, + int filter_id) { + std::shared_ptr filter(create_set(TYPE_BIGINT, false)); + for (const auto value : values) { + filter->insert(&value); + } + TExprNode node; + node.__set_type(std::make_shared()->to_thrift()); + node.__set_node_type(TExprNodeType::IN_PRED); + node.in_predicate.__set_is_not_in(false); + node.__set_opcode(TExprOpcode::FILTER_IN); + node.__set_is_nullable(false); + auto predicate = VDirectInPredicate::create_shared(node, std::move(filter), true); + predicate->add_child(VSlotRef::create_shared( + 0, 0, -1, make_nullable(std::make_shared()), std::move(column_name))); + return VExprContext::create_shared( + RuntimeFilterExpr::create_shared(node, std::move(predicate), 0.0, false, filter_id)); +} + void add_output_columns(Block* block, const Columns& columns) { for (const auto& column : columns) { block->insert({column.type->create_column(), column.type, column.name}); @@ -635,7 +661,7 @@ TEST(LanceTableReaderVectorSearchTest, ReadsOnlyGlobalRowIdVirtualColumn) { EXPECT_TRUE(reader.close().ok()); } -TEST(LanceTableReaderFilterTest, PushesFilterOnNonProjectedColumn) { +TEST(LanceTableReaderFilterTest, CombinesStaticSubstraitFilterWithRuntimeFilter) { const std::filesystem::path dataset_uri = "./be/test/format_v2/table/lance/data/all_types.lance"; LanceFixtureInfo fixture; @@ -712,6 +738,85 @@ TEST(LanceTableReaderFilterTest, PushesFilterOnNonProjectedColumn) { std::ranges::sort(labels); EXPECT_EQ((std::vector {"extra", "mixed"}), labels); EXPECT_TRUE(reader.close().ok()); + + // The static Substrait prefilter and a later runtime filter must both remain active. Static + // row_id >= 3 yields {3, 4}; runtime row_id IN (2, 4) yields {2, 4}; their intersection is {4}. + std::string combined_substrait_filter; + ASSERT_TRUE(base64_decode(substrait_filter_base64, &combined_substrait_filter)); + TLanceScanParams combined_lance_scan_params; + combined_lance_scan_params.__set_lance_substrait_filter(std::move(combined_substrait_filter)); + TFileScanRangeParams combined_scan_params; + combined_scan_params.__set_lance_scan_params(std::move(combined_lance_scan_params)); + const Columns row_id_columns {projected_column("row_id", TYPE_BIGINT, false)}; + RuntimeProfile combined_profile("lance_substrait_and_runtime_filter_fixture"); + const auto combined_runtime_filter = create_int64_runtime_in_conjunct("row_id", {2, 4}, 42); + + LanceTableReader combined_reader; + ASSERT_TRUE(init_reader(&combined_reader, row_id_columns, &state, &combined_profile, + &combined_scan_params, {combined_runtime_filter}) + .ok()); + ASSERT_TRUE(prepare_fixture(&combined_reader, dataset_uri, fixture, fixture.fragment_ids).ok()); + + Block combined_block; + add_output_columns(&combined_block, row_id_columns); + std::vector combined_row_ids; + eos = false; + while (!eos) { + ASSERT_TRUE(combined_reader.get_block(&combined_block, &eos).ok()); + if (eos) { + continue; + } + const auto& row_ids = + assert_cast(*combined_block.get_by_position(0).column); + combined_row_ids.insert(combined_row_ids.end(), row_ids.get_data().begin(), + row_ids.get_data().end()); + } + EXPECT_EQ((std::vector {4}), combined_row_ids); + ASSERT_NE(combined_profile.get_info_string("LanceRuntimeFilterPushedIds"), nullptr); + EXPECT_EQ("42", *combined_profile.get_info_string("LanceRuntimeFilterPushedIds")); + EXPECT_TRUE(combined_reader.close().ok()); +} + +TEST(LanceTableReaderFilterTest, PushesRuntimeInFilterIntoLanceScanner) { + const std::filesystem::path dataset_uri = + "./be/test/format_v2/table/lance/data/all_types.lance"; + LanceFixtureInfo fixture; + ASSERT_TRUE(get_fixture_info(dataset_uri, &fixture).ok()); + + const Columns columns {projected_column("row_id", TYPE_BIGINT, false)}; + TQueryOptions query_options; + query_options.__set_batch_size(4); + TQueryGlobals query_globals; + RuntimeState state(query_globals); + state.set_query_options(query_options); + RuntimeProfile profile("lance_runtime_filter_pushdown_fixture"); + TFileScanRangeParams scan_params; + const auto runtime_filter = create_int64_runtime_in_conjunct("row_id", {2, 4}, 41); + + LanceTableReader reader; + ASSERT_TRUE( + init_reader(&reader, columns, &state, &profile, &scan_params, {runtime_filter}).ok()); + ASSERT_TRUE(prepare_fixture(&reader, dataset_uri, fixture, fixture.fragment_ids).ok()); + + Block block; + add_output_columns(&block, columns); + std::vector actual_row_ids; + bool eos = false; + while (!eos) { + ASSERT_TRUE(reader.get_block(&block, &eos).ok()); + if (eos) { + continue; + } + const auto& row_ids = assert_cast(*block.get_by_position(0).column); + actual_row_ids.insert(actual_row_ids.end(), row_ids.get_data().begin(), + row_ids.get_data().end()); + } + std::ranges::sort(actual_row_ids); + EXPECT_EQ((std::vector {2, 4}), actual_row_ids); + ASSERT_NE(profile.get_info_string("LanceRuntimeFilterPushedIds"), nullptr); + EXPECT_EQ("41", *profile.get_info_string("LanceRuntimeFilterPushedIds")); + EXPECT_EQ(profile.get_info_string("LanceRuntimeFilterSkippedIds"), nullptr); + EXPECT_TRUE(reader.close().ok()); } TEST(LanceTableReaderFilterTest, LeavesResidualPredicatesToScanner) { diff --git a/regression-test/data/external_table_p0/lance/test_lance_runtime_filter_pushdown.out b/regression-test/data/external_table_p0/lance/test_lance_runtime_filter_pushdown.out new file mode 100644 index 00000000000000..d22abdc0d07eda --- /dev/null +++ b/regression-test/data/external_table_p0/lance/test_lance_runtime_filter_pushdown.out @@ -0,0 +1,18 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !runtime_filter_and_substrait -- +7 10 +9 100 + +-- !two_phase_vector_search -- +1 even item-0001 0.0 +2 odd item-0002 16.0 +3 even item-0003 64.0 +4 odd item-0004 144.0 +5 even item-0005 256.0 + +-- !explicit_join_vector_search -- +1 even item-0001 0.0 +2 odd item-0002 16.0 +3 even item-0003 64.0 +4 odd item-0004 144.0 +5 even item-0005 256.0 diff --git a/regression-test/suites/external_table_p0/lance/test_lance_runtime_filter_pushdown.groovy b/regression-test/suites/external_table_p0/lance/test_lance_runtime_filter_pushdown.groovy new file mode 100644 index 00000000000000..870f51cd7b2b9a --- /dev/null +++ b/regression-test/suites/external_table_p0/lance/test_lance_runtime_filter_pushdown.groovy @@ -0,0 +1,158 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_lance_runtime_filter_pushdown", "p0,external") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable Lance runtime-filter test because the Iceberg MinIO environment is disabled.") + return + } + + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String catalogName = "test_lance_runtime_filter_pushdown" + String buildTable = "test_lance_runtime_filter_build" + String internalDb = context.dbName + String lanceTable = "`${catalogName}`.`doris`.`predicate_pushdown`" + + sql "SWITCH internal" + sql "USE `${internalDb}`" + sql "DROP TABLE IF EXISTS `${buildTable}`" + sql "DROP CATALOG IF EXISTS `${catalogName}`" + + try { + sql """ + CREATE TABLE `${buildTable}` ( + id BIGINT NOT NULL + ) ENGINE=OLAP + DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ( + "replication_allocation" = "tag.location.default: 1" + ) + """ + sql "INSERT INTO `${buildTable}` VALUES (4), (7), (9), (11)" + + sql """ + CREATE CATALOG `${catalogName}` PROPERTIES ( + "type" = "lance", + "lance.catalog.type" = "filesystem", + "warehouse" = "s3://warehouse/lance", + "s3.endpoint" = "http://${externalEnvIp}:${minioPort}", + "s3.access_key" = "admin", + "s3.secret_key" = "password", + "s3.region" = "us-east-1", + "use_path_style" = "true" + ) + """ + + sql "SET enable_file_scanner_v2 = true" + sql "SET enable_sql_cache = false" + sql "SET enable_query_cache = false" + sql "SET runtime_filter_mode = 'GLOBAL'" + sql "SET runtime_filter_type = 'IN'" + sql "SET runtime_filter_wait_infinitely = true" + sql "SET enable_runtime_filter_prune = false" + + String query = """ + SELECT /*+ leading(l broadcast b) */ l.row_id, l.int64_value + FROM ${lanceTable} l + INNER JOIN `internal`.`${internalDb}`.`${buildTable}` b + ON l.row_id = b.id + WHERE l.int64_value >= 10 + ORDER BY l.row_id + """ + + // The regular WHERE predicate is converted to the primary Substrait filter. The join + // produces an IN runtime filter on row_id, which must be attached to the Lance scan. + explain { + sql "verbose ${query}" + check { explainString -> + assertTrue(explainString.contains("VLANCE_SCAN_NODE")) + assertTrue(explainString.contains("lancePushdownPredicate=")) + assertTrue(explainString.contains("int64_value")) + assertTrue(explainString.contains("runtime filters: RF")) + assertTrue(explainString.contains("-> row_id")) + return true + } + } + + // Build-side IDs are 4, 7, 9 and 11. The static Lance predicate keeps only rows whose + // int64_value is at least 10, so the intersection is exactly rows 7 and 9. In particular, + // this catches an implementation that replaces the Substrait filter with the later RF. + qt_runtime_filter_and_substrait "${query}" + + // Compare Doris's dedicated two-phase row-id fetch with the equivalent SQL formulation: + // first produce narrow ANN candidates, then broadcast them and fetch payload columns from + // a normal Lance scan. Both queries must return the same ordered rows and distances. + String vectorTable = "${catalogName}.doris.vs_ivf_pq_f32" + String qualifiedVectorTable = "`${catalogName}`.`doris`.`vs_ivf_pq_f32`" + String headQuery = "[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]" + String vectorSearch = """vector_search( + "table"="${vectorTable}", + "column"="embedding", + "query_vector"="${headQuery}", + "top_k"="5", + "metric"="l2", + "nprobes"="4", + "refine_factor"="10", + "use_index"="true")""" + String twoPhaseQuery = """ + SELECT row_id, category, label, _distance + FROM ${vectorSearch} + ORDER BY _distance, row_id + """ + + sql "SET topn_lazy_materialization_threshold = 1024" + explain { + sql "verbose ${twoPhaseQuery}" + contains "VMaterializeNode" + contains "__DORIS_GLOBAL_ROWID_COL__vector_search" + } + qt_two_phase_vector_search "${twoPhaseQuery}" + + String explicitJoinQuery = """ + WITH candidates AS ( + SELECT row_id, _distance + FROM ${vectorSearch} + ORDER BY _distance, row_id + LIMIT 5 + ) + SELECT w.row_id, w.category, w.label, c._distance + FROM ${qualifiedVectorTable} w + JOIN [broadcast] candidates c ON w.row_id = c.row_id + ORDER BY c._distance, c.row_id + """ + + sql "SET topn_lazy_materialization_threshold = -1" + sql "SET disable_join_reorder = true" + explain { + sql "verbose ${explicitJoinQuery}" + contains "VHASH JOIN" + contains "JOIN(BROADCAST)" + contains "runtime filters: RF" + contains "-> row_id" + notContains "VMaterializeNode" + } + qt_explicit_join_vector_search "${explicitJoinQuery}" + } finally { + // sql "SWITCH internal" + // sql "USE `${internalDb}`" + // sql "DROP TABLE IF EXISTS `${buildTable}`" + // sql "DROP CATALOG IF EXISTS `${catalogName}`" + } +} diff --git a/thirdparty/download-thirdparty.sh b/thirdparty/download-thirdparty.sh index bb9c5928809170..85631eaadda648 100755 --- a/thirdparty/download-thirdparty.sh +++ b/thirdparty/download-thirdparty.sh @@ -774,7 +774,7 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " PAIMON_CPP " ]]; then echo "Finished patching ${PAIMON_CPP_SOURCE}" fi -# Patch lance-c with the scan execution statistics API from upstream PR #64. +# Apply Doris lance-c patches in dependency order. if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.7" ]]; then cd "${TP_SOURCE_DIR}/${LANCE_C_SOURCE}" @@ -782,6 +782,11 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.7-pr-64.patch" touch "${PATCHED_MARK}" fi + lance_runtime_filter_mark="patched_mark_runtime_filter" + if [[ ! -f "${lance_runtime_filter_mark}" ]]; then + patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.7-runtime-filter.patch" + touch "${lance_runtime_filter_mark}" + fi cd - fi echo "Finished patching ${LANCE_C_SOURCE}" diff --git a/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch b/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch new file mode 100644 index 00000000000000..dd12ca19ab7df6 --- /dev/null +++ b/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch @@ -0,0 +1,4904 @@ +diff --git a/AGENTS.md b/AGENTS.md +index a1c0041..f236494 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -2,8 +2,8 @@ + + ## Structure + Rust FFI source: `src/` +-C header (stable ABI): `include/lance.h` +-C++ RAII wrappers (header-only): `include/lance.hpp` ++C header (stable ABI): `include/lance/lance.h` ++C++ RAII wrappers (header-only): `include/lance/lance.hpp` + Tests (Rust): `tests/c_api_test.rs` + Tests (C/C++): `tests/cpp/` + Historical test data: `test_data/` +@@ -19,7 +19,8 @@ test C/C++ compilation: `cargo test --test compile_and_run_test -- --ignored` + - Opaque handles with `lance_*_open`/`lance_*_close` lifecycle. + - Thread-local error handling via `ffi_try!` macro. + - Arrow C Data Interface for zero-copy data exchange. +-- `panic = "abort"` in release to prevent unwinding across FFI. ++- `panic = "unwind"` is required so guarded FFI boundaries can translate ++ panics to `LANCE_ERR_PANIC`; `panic = "abort"` builds are rejected. + + ## Coding Standards + +diff --git a/Cargo.toml b/Cargo.toml +index 5bc30bd..b5ba59a 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -25,6 +25,8 @@ lance-index = { git = "https://github.com/lance-format/lance.git", rev = "e934cc + lance-io = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } + lance-linalg = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } + lance-table = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } ++lance-datafusion = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c", features = ["substrait"] } ++datafusion = { version = "54.0.0", default-features = false } + arrow = { version = "58.0.0", features = ["prettyprint", "ffi"] } + arrow-array = "58.0.0" + arrow-schema = "58.0.0" +@@ -44,10 +46,8 @@ uuid = { version = "1", features = ["v4"] } + + [dev-dependencies] + lance = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c", features = ["substrait"] } +-lance-datafusion = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c", features = ["substrait"] } + lance-datagen = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } + lance-file = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } +-datafusion = { version = "54.0.0", default-features = false } + tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + arrow-array = "58.0.0" + arrow-schema = "58.0.0" +diff --git a/README.md b/README.md +index 436c2f1..d6ae6c8 100644 +--- a/README.md ++++ b/README.md +@@ -67,7 +67,7 @@ Based on the [liblance RFC](https://github.com/lance-format/lance/discussions/60 + |--------|-----------|-------------| + | [x] | Async scan | Callback-based `lance_scanner_scan_async()` for non-blocking scans | + | [x] | Dataset metadata | `lance_dataset_version()`, `lance_dataset_count_rows()`, `lance_dataset_latest_version()` | +-| [x] | Substrait filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression` (preferred over SQL strings for query engines) | ++| [x] | Filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression`; `lance_scanner_additional_sql_filter()` adds SQL predicates with AND before scanning starts | + + ## Building + +diff --git a/include/lance/lance.h b/include/lance/lance.h +index 5b12f3d..ccd7e8c 100644 +--- a/include/lance/lance.h ++++ b/include/lance/lance.h +@@ -7,10 +7,15 @@ + * + * All data crosses this boundary via the Arrow C Data Interface + * (ArrowSchema, ArrowArray, ArrowArrayStream). ++ * For Arrow structures written to caller-provided output storage, the caller ++ * retains ownership of the outer structure and must invoke its non-NULL ++ * `release` callback exactly once to release the contents. APIs that allocate ++ * the outer structure as well document a separate matching free function. + * +- * Error handling uses thread-local storage: after any function returns +- * NULL (pointer) or -1 (int), call lance_last_error_code() and +- * lance_last_error_message() to get details. ++ * Error handling uses thread-local storage: after any function returns its ++ * documented error sentinel (for example NULL, -1, or 0 for selected scalar ++ * accessors), call lance_last_error_code() and lance_last_error_message() to ++ * get details. + */ + + #ifndef LANCE_H +@@ -100,16 +105,21 @@ typedef enum { + * Honest limits: a double panic, a panic in a destructor while unwinding, a + * stack overflow, or an allocation failure still aborts the process. A + * panic caught inside a close/free call (lance_*_close, lance_batch_free, +- * lance_free_string, or the release callback of an exported +- * ArrowArrayStream) is logged and the remainder of the value may leak — +- * close is best-effort by design. Post-panic process state is best-effort: +- * hosts should fail the in-flight query rather than retry a poisoned +- * handle. ++ * lance_free_string, lance_scanner_async_stream_free, or the release callback ++ * of an exported ArrowArrayStream) is logged and the remainder of the value ++ * may leak — close is best-effort by design. Post-panic process state is ++ * best-effort: hosts should fail the in-flight query rather than retry a ++ * poisoned handle. + * +- * Callbacks passed INTO the library (LanceCallback, LanceWaker) are the +- * reverse direction and are NOT covered by this contract: their ABI is +- * non-unwinding, so a panicking callback aborts the host process before +- * the library can contain it. Callbacks must not panic. ++ * Callbacks passed INTO the library (LanceCallback, LanceWaker, and ++ * LanceScanStatisticsCallback) are the reverse direction and are NOT covered ++ * by this contract: their ABI is non-unwinding, so a callback that throws or ++ * unwinds can abort the host process before the library can contain it. ++ * Callbacks must return normally. ++ * ++ * This contract requires Rust's `panic = "unwind"` strategy. The crate ++ * rejects `panic = "abort"` builds at compile time because catch_unwind ++ * cannot provide this API contract in such a build. + */ + + /* ─── Index types (Phase 2) ─── */ +@@ -175,6 +185,7 @@ typedef struct LanceVersions LanceVersions; + typedef struct LanceDataStatistics LanceDataStatistics; + typedef struct LanceIndexSegmentBuilder LanceIndexSegmentBuilder; + typedef struct LanceIndexSegmentMetadata LanceIndexSegmentMetadata; ++typedef struct LanceFtsQueryContext LanceFtsQueryContext; + + /* ─── Dataset lifecycle ─── */ + +@@ -202,13 +213,22 @@ void lance_dataset_close(LanceDataset* dataset); + + /* ─── Dataset metadata (sync, in-memory) ─── */ + +-/** Return the version number of this dataset snapshot. */ ++/** ++ * Return the version number of this dataset snapshot. ++ * @return version on success, or 0 on error (check lance_last_error_code()) ++ */ + uint64_t lance_dataset_version(const LanceDataset* dataset); + +-/** Return the number of rows. Returns 0 on error. */ ++/** ++ * Return the number of rows. Returns 0 on error; an empty dataset also returns ++ * 0, so check lance_last_error_code(). ++ */ + uint64_t lance_dataset_count_rows(const LanceDataset* dataset); + +-/** Return the latest version ID (I/O). Returns 0 on error. */ ++/** ++ * Return the latest version ID (I/O), or 0 on error (check ++ * lance_last_error_code()). ++ */ + uint64_t lance_dataset_latest_version(const LanceDataset* dataset); + + /* ─── Version history ─── */ +@@ -220,7 +240,10 @@ uint64_t lance_dataset_latest_version(const LanceDataset* dataset); + */ + LanceVersions* lance_dataset_versions(const LanceDataset* dataset); + +-/** Number of versions in the snapshot. Returns 0 on error. */ ++/** ++ * Number of versions in the snapshot, or 0 on error (check ++ * lance_last_error_code()). ++ */ + uint64_t lance_versions_count(const LanceVersions* versions); + + /** +@@ -755,7 +778,10 @@ int32_t lance_dataset_schema( + + /* ─── Fragment enumeration ─── */ + +-/** Return the number of fragments in the dataset. Returns 0 on error. */ ++/** ++ * Return the number of fragments in the dataset. Returns 0 on error; a ++ * dataset with no fragments also returns 0, so check lance_last_error_code(). ++ */ + uint64_t lance_dataset_fragment_count(const LanceDataset* dataset); + + /** +@@ -769,6 +795,15 @@ int32_t lance_dataset_fragment_ids(const LanceDataset* dataset, uint64_t* out_id + + /** + * Take rows by indices. ++ * ++ * On success, `out` is initialized in caller-owned storage; the caller must ++ * eventually invoke its non-NULL `release` callback exactly once. The schema ++ * is validated before the stream callbacks are exposed. A deferred iteration ++ * failure, including a caught panic in `get_next`, is reported through the ++ * Arrow C stream contract (nonzero `get_next` plus `get_last_error`). A panic ++ * during `release` cleanup is contained and logged; cleanup remains ++ * best-effort. ++ * + * @param indices Array of 0-based row offsets + * @param num_indices Length of indices array + * @param columns NULL-terminated column names, or NULL for all +@@ -791,6 +826,14 @@ int32_t lance_dataset_take( + * Missing or deleted row IDs may be omitted from the result. For found rows, + * input order and duplicates are preserved. + * ++ * On success, `out` is initialized in caller-owned storage; the caller must ++ * eventually invoke its non-NULL `release` callback exactly once. The schema ++ * is validated before the stream callbacks are exposed. A deferred iteration ++ * failure, including a caught panic in `get_next`, is reported through the ++ * Arrow C stream contract (nonzero `get_next` plus `get_last_error`). A panic ++ * during `release` cleanup is contained and logged; cleanup remains ++ * best-effort. ++ * + * @param dataset Open dataset snapshot. + * @param row_ids Array of dataset row IDs. May be NULL only when + * `num_row_ids` is zero. +@@ -863,6 +906,22 @@ int32_t lance_scanner_set_substrait_filter( + size_t len + ); + ++/** ++ * Add an SQL filter that is combined with the selected primary filter using ++ * AND. The primary filter is the Substrait filter when set, otherwise it is ++ * the SQL filter passed to `lance_scanner_new`. Multiple additional SQL ++ * filters are also combined using AND. ++ * ++ * Must be called before the scan starts. The filter string is copied. ++ * ++ * @param filter Non-NULL, non-empty SQL filter expression ++ * @return 0 on success, -1 on error ++ */ ++int32_t lance_scanner_additional_sql_filter( ++ LanceScanner* scanner, ++ const char* filter ++); ++ + /** Type of a dynamically named scan metric. */ + typedef enum { + LANCE_SCAN_METRIC_COUNT = 0, +@@ -967,7 +1026,16 @@ int32_t lance_scanner_set_statistics_callback( + void* callback_ctx + ); + +-/** Close and free a scanner handle. */ ++/** ++ * Close and free a scanner handle. Safe to call with NULL; a non-NULL handle ++ * must be closed exactly once. ++ * ++ * This is the retirement boundary for poll wakers registered by ++ * lance_scanner_poll_next(): it cancels callbacks that have not entered and ++ * waits for any callback already in progress to return before freeing the ++ * scanner. Do not call this function from one of the scanner's own waker ++ * callbacks, because close must wait for that callback to return. ++ */ + void lance_scanner_close(LanceScanner* scanner); + + /* ─── Sync scan: ArrowArrayStream ─── */ +@@ -975,6 +1043,10 @@ void lance_scanner_close(LanceScanner* scanner); + /** + * Materialize the scan as an ArrowArrayStream (blocking). + * The scanner remains valid, and each call creates an independent stream. ++ * `out` points to caller-owned storage. On success, the caller must eventually ++ * invoke `out->release(out)` exactly once when `release` is non-NULL; that ++ * releases the stream contents but not the caller-owned outer structure. Do ++ * not pass this caller-allocated stream to lance_scanner_async_stream_free(). + * + * Reading the exported stream may surface a mid-iteration panic as one + * error through the Arrow C stream contract (nonzero get_next plus +@@ -1005,14 +1077,18 @@ int32_t lance_scanner_next( + /** + * Callback type for async operations. + * +- * The callback runs on the dispatcher thread; on failure the error code and +- * message are installed in that thread's thread-local storage immediately +- * before the callback runs, so lance_last_error_* called from inside the +- * callback observes this completion's failure. ++ * The callback normally runs on the dedicated dispatcher thread. During a ++ * rare dispatcher startup or delivery failure, completion falls back to the ++ * thread that detects the failure (for example the calling or producing ++ * thread), so the callback must be thread-safe. On failure the error code and ++ * message are installed on the actual callback thread immediately before the ++ * callback runs, so lance_last_error_* called from inside the callback ++ * observes this completion's failure. + * +- * Callbacks must not panic: the callback ABI is non-unwinding, so a +- * panicking callback aborts the host process before the dispatcher can +- * contain it. ++ * Callbacks must return normally: the callback ABI is non-unwinding, so a ++ * callback that throws or unwinds can abort the host process before the ++ * dispatcher can contain it. ++ * A callback passed to lance_scanner_scan_async() must not be NULL. + * + * @param ctx Opaque pointer passed back from the caller + * @param status 0 = success, -1 = error +@@ -1021,14 +1097,28 @@ int32_t lance_scanner_next( + typedef void (*LanceCallback)(void* ctx, int32_t status, void* result); + + /** +- * Start an async scan. The callback fires on a dedicated dispatcher thread +- * when the ArrowArrayStream is ready. ++ * Start an async scan. The callback normally fires on a dedicated dispatcher ++ * thread when the ArrowArrayStream is ready. During a rare dispatcher ++ * infrastructure failure it may instead run on the calling or producing ++ * thread, so it must be thread-safe. ++ * ++ * For a non-NULL callback, exactly one completion is delivered, including for ++ * validation, setup, task, and dispatcher failures. The fallback path may ++ * invoke it before lance_scanner_scan_async() returns. `callback` and a ++ * non-NULL `callback_ctx` must remain valid until that invocation returns. ++ * ++ * `callback` must not be NULL; `callback_ctx` may be NULL. On success, result ++ * is a library-allocated ArrowArrayStream owned by the caller. The caller must ++ * eventually pass it exactly once to lance_scanner_async_stream_free(), even ++ * if it has already invoked the stream's release callback directly. Do not ++ * free the returned outer structure with free(), delete, or a platform ++ * allocator. + * + * On failure the callback receives status -1 with result NULL, and the +- * error code/message are installed in the dispatcher thread's thread-local +- * storage immediately before the callback runs (per completion). A panic in +- * the scan task also yields status -1 with LANCE_ERR_PANIC and poisons the +- * scanner handle. ++ * error code/message are installed in the actual callback thread's ++ * thread-local storage immediately before the callback runs (per completion). ++ * A panic in the scan task also yields status -1 with LANCE_ERR_PANIC and ++ * poisons the scanner handle. + */ + void lance_scanner_scan_async( + const LanceScanner* scanner, +@@ -1036,6 +1126,19 @@ void lance_scanner_scan_async( + void* callback_ctx + ); + ++/** ++ * Release and free an ArrowArrayStream returned by a successful ++ * lance_scanner_scan_async() callback. ++ * ++ * If `stream->release` is non-NULL, this function invokes it before freeing ++ * the library-allocated outer structure. It is therefore valid both before ++ * and after a consumer has directly released the stream contents. `stream` ++ * may be NULL. A non-NULL pointer must be passed exactly once and must be the ++ * pointer delivered by lance_scanner_scan_async(); using this function for a ++ * caller-allocated ArrowArrayStream is invalid. ++ */ ++void lance_scanner_async_stream_free(struct ArrowArrayStream* stream); ++ + /* ─── Poll-based scan (for cooperative async runtimes) ─── */ + + typedef enum { +@@ -1045,12 +1148,26 @@ typedef enum { + LANCE_POLL_ERROR = -1, + } LancePollStatus; + +-/** Waker callback: called from a Tokio thread when data is ready. */ ++/** ++ * Waker callback: called from a Tokio thread when data is ready. A waker ++ * passed to lance_scanner_poll_next() must not be NULL. For one poll call ++ * that returns LANCE_POLL_PENDING, all internal RawWaker clones share a ++ * one-shot gate, so the callback fires at most once. ++ * ++ * The callback and `ctx` must be thread-safe and must remain valid until the ++ * callback returns or lance_scanner_close() returns. Close cancels a pending ++ * callback and waits for an active callback before returning, so the caller ++ * may destroy `ctx` afterwards. The callback must return normally and must ++ * not call lance_scanner_close() or otherwise re-enter its originating ++ * scanner. ++ */ + typedef void (*LanceWaker)(void* ctx); + + /** + * Poll for the next batch without blocking. +- * See RFC for usage pattern. ++ * `waker` must not be NULL; `waker_ctx` may be NULL. `out` is set to a ++ * LanceBatch only for LANCE_POLL_READY and is set to NULL for ++ * LANCE_POLL_PENDING, LANCE_POLL_FINISHED, and LANCE_POLL_ERROR. + */ + LancePollStatus lance_scanner_poll_next( + LanceScanner* scanner, +@@ -1329,7 +1446,10 @@ const char* lance_index_segment_metadata_name( + const LanceIndexSegmentMetadata* metadata + ); + +-/** Return the dataset version against which the segment was built. */ ++/** ++ * Return the dataset version against which the segment was built, or 0 on ++ * error (check lance_last_error_code()). ++ */ + uint64_t lance_index_segment_metadata_dataset_version( + const LanceIndexSegmentMetadata* metadata + ); +@@ -1355,7 +1475,10 @@ const char* lance_index_segment_metadata_index_details_type_url( + const LanceIndexSegmentMetadata* metadata + ); + +-/** Return the number of indexed field IDs. */ ++/** ++ * Return the number of indexed field IDs. Returns 0 on error; zero may also be ++ * a valid count, so check lance_last_error_code(). ++ */ + size_t lance_index_segment_metadata_field_count( + const LanceIndexSegmentMetadata* metadata + ); +@@ -1368,7 +1491,10 @@ int32_t lance_index_segment_metadata_field_ids( + size_t* out_count + ); + +-/** Return the number of fragment IDs covered by the segment. */ ++/** ++ * Return the number of fragment IDs covered by the segment. Returns 0 on ++ * error; zero may also be a valid count, so check lance_last_error_code(). ++ */ + size_t lance_index_segment_metadata_fragment_count( + const LanceIndexSegmentMetadata* metadata + ); +@@ -1393,7 +1519,11 @@ void lance_index_segment_metadata_free(LanceIndexSegmentMetadata* metadata); + /** Drop an index by name. Returns -1 (NOT_FOUND) if no such index. */ + int32_t lance_dataset_drop_index(LanceDataset* dataset, const char* name); + +-/** Number of user indexes (excludes system indexes). Returns 0 on error. */ ++/** ++ * Number of user indexes (excludes system indexes). Returns 0 on error; a ++ * dataset with no user indexes also returns 0, so check ++ * lance_last_error_code(). ++ */ + uint64_t lance_dataset_index_count(const LanceDataset* dataset); + + /** +@@ -1489,6 +1619,56 @@ int32_t lance_scanner_set_index_segments( + + /* ─── Full-text search (Phase 2) ─── */ + ++/** ++ * Required relationship between a pinned dataset snapshot and its committed ++ * FTS index segments. Values are ABI-stable; API parameters use int32_t. ++ */ ++typedef enum { ++ /** Fail prepare if any current fragment is not covered by the FTS index. */ ++ LANCE_FTS_COVERAGE_STRICT = 0, ++ /** Score and search only rows covered by committed FTS index segments. */ ++ LANCE_FTS_COVERAGE_INDEX_ONLY = 1, ++} LanceFtsCoverageMode; ++ ++/** ++ * Prepare an immutable, process-local FTS query context for one column. ++ * ++ * Preparation pins the dataset handle's current snapshot, enumerates all ++ * committed FTS segments for `column`, checks fragment coverage, opens those ++ * segments, and computes one query-specific global BM25 scorer across their ++ * indexed documents. The context can then be shared by any number of scanners ++ * created from the exact same process-local dataset snapshot. It has no ++ * serialization or cross-process transport format. Reopening the same URI and ++ * manifest version creates a different identity and cannot reuse the context, ++ * because storage options and object-store endpoints may differ. ++ * ++ * In LANCE_FTS_COVERAGE_INDEX_ONLY mode, unindexed fragments are allowed and ++ * excluded from both the scorer corpus and query results. In STRICT mode any ++ * unindexed fragment makes this call fail. ++ * ++ * Prepared contexts currently support exact Match queries only. ++ * `max_fuzzy_distance` must be zero because fuzzy execution requires its ++ * canonical expanded vocabulary to be prepared together with the scorer. ++ * This restriction does not apply to lance_scanner_full_text_search(). ++ * ++ * @param max_fuzzy_distance Must be zero for prepared query contexts. ++ * @param coverage_mode Fixed-width LanceFtsCoverageMode discriminant. ++ * @return Context handle on success, or NULL on error. ++ */ ++LanceFtsQueryContext* lance_dataset_prepare_fts_query( ++ const LanceDataset* dataset, ++ const char* column, ++ const char* query, ++ uint32_t max_fuzzy_distance, ++ int32_t coverage_mode ++); ++ ++/** ++ * Close a context handle. NULL-safe. Scanners that already attached this ++ * context retain shared ownership and remain valid. ++ */ ++void lance_fts_query_context_close(LanceFtsQueryContext* context); ++ + /** + * Set a BM25 full-text search query on the scanner. + * +@@ -1508,6 +1688,30 @@ int32_t lance_scanner_full_text_search( + uint32_t max_fuzzy_distance + ); + ++/** ++ * Attach a prepared process-local FTS query context. The scanner must have ++ * been created from the exact LanceDataset snapshot used to prepare the ++ * context; URI and manifest version equality is not sufficient. The scanner ++ * retains shared ownership, so the caller may close `context` after success. ++ * This is mutually exclusive with nearest and lance_scanner_full_text_search ++ * because the context already owns the FTS query. ++ */ ++int32_t lance_scanner_set_fts_query_context( ++ LanceScanner* scanner, ++ const LanceFtsQueryContext* context ++); ++ ++/** ++ * Restrict a context-backed FTS scan to `len` context segment UUIDs supplied ++ * by the caller's planner. Pass `len == 0` to clear the restriction and search ++ * all context segments. Duplicate or unknown UUIDs are rejected. ++ */ ++int32_t lance_scanner_set_fts_index_segments( ++ LanceScanner* scanner, ++ const uint8_t* segment_uuids, ++ size_t len ++); ++ + /* ─── Dataset writer ─── */ + + /** +diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp +index 8aa97e2..330e419 100644 +--- a/include/lance/lance.hpp ++++ b/include/lance/lance.hpp +@@ -49,6 +49,12 @@ inline void check_error() { + } + } + ++/// Release and free a library-allocated ArrowArrayStream returned by ++/// Scanner::scan_async. NULL-safe; do not use for caller-allocated streams. ++inline void scanner_async_stream_free(ArrowArrayStream* stream) noexcept { ++ lance_scanner_async_stream_free(stream); ++} ++ + // ─── RAII Handle Template ──────────────────────────────────────────────────── + + template +@@ -89,6 +95,7 @@ class Scanner; + class IndexModel; + class IndexSegmentBuilder; + class IndexSegmentMetadata; ++class FtsQueryContext; + + // ─── Version history ───────────────────────────────────────────────────────── + +@@ -116,6 +123,11 @@ enum class WriteMode : int32_t { + Overwrite = LANCE_WRITE_OVERWRITE, + }; + ++enum class FtsCoverageMode : int32_t { ++ Strict = LANCE_FTS_COVERAGE_STRICT, ++ IndexOnly = LANCE_FTS_COVERAGE_INDEX_ONLY, ++}; ++ + /// Tunable parameters for Dataset::write. Numeric fields default-out via 0; + /// `data_storage_version` defaults out via `std::nullopt`. + /// +@@ -157,6 +169,24 @@ struct SqlColumn { + std::string expression; + }; + ++// ─── Process-local FTS query context ──────────────────────────────────────── ++ ++/// Immutable, query-specific global BM25 scorer plus pinned FTS segment list. ++/// This handle is process-local and intentionally has no serialization API. ++class FtsQueryContext { ++ Handle handle_; ++ ++public: ++ explicit FtsQueryContext(LanceFtsQueryContext* context) : handle_(context) {} ++ ++ FtsQueryContext(FtsQueryContext&&) noexcept = default; ++ FtsQueryContext& operator=(FtsQueryContext&&) noexcept = default; ++ FtsQueryContext(const FtsQueryContext&) = delete; ++ FtsQueryContext& operator=(const FtsQueryContext&) = delete; ++ ++ const LanceFtsQueryContext* c_handle() const { return handle_.get(); } ++}; ++ + // ─── Dataset ───────────────────────────────────────────────────────────────── + + class Dataset { +@@ -329,7 +359,9 @@ public: + + /// Version of this dataset snapshot. + uint64_t version() const { +- return lance_dataset_version(handle_.get()); ++ uint64_t v = lance_dataset_version(handle_.get()); ++ if (lance_last_error_code() != LANCE_OK) check_error(); ++ return v; + } + + /// Latest version ID (queries object store). +@@ -347,11 +379,13 @@ public: + Handle snap(raw); + + uint64_t n = lance_versions_count(snap.get()); ++ if (lance_last_error_code() != LANCE_OK) check_error(); + std::vector out; + out.reserve(static_cast(n)); + for (uint64_t i = 0; i < n; i++) { + VersionInfo info; + info.id = lance_versions_id_at(snap.get(), static_cast(i)); ++ if (lance_last_error_code() != LANCE_OK) check_error(); + info.timestamp_ms = + lance_versions_timestamp_ms_at(snap.get(), static_cast(i)); + if (lance_last_error_code() != LANCE_OK) check_error(); +@@ -369,11 +403,13 @@ public: + Handle snap(raw); + + uint64_t n = lance_data_statistics_count(snap.get()); ++ if (lance_last_error_code() != LANCE_OK) check_error(); + std::vector out; + out.reserve(static_cast(n)); + for (uint64_t i = 0; i < n; i++) { + FieldStatistics fs; + fs.id = lance_data_statistics_field_id_at(snap.get(), static_cast(i)); ++ if (lance_last_error_code() != LANCE_OK) check_error(); + fs.bytes_on_disk = + lance_data_statistics_bytes_on_disk_at(snap.get(), static_cast(i)); + if (lance_last_error_code() != LANCE_OK) check_error(); +@@ -631,7 +667,9 @@ public: + } + } + +- /// Take rows by indices. Results exported as ArrowArrayStream. ++ /// Take rows by indices. `out` is caller-owned and its non-null `release` ++ /// must be called exactly once. Deferred iteration/cleanup panics are ++ /// contained by the exported stream guard. + void take(const uint64_t* indices, size_t num_indices, + const std::vector& columns, + ArrowArrayStream* out) const { +@@ -645,7 +683,7 @@ public: + } + } + +- /// Take all columns. ++ /// Take all columns with the same stream ownership as the overload above. + void take(const uint64_t* indices, size_t num_indices, + ArrowArrayStream* out) const { + if (lance_dataset_take(handle_.get(), indices, num_indices, nullptr, out) != 0) { +@@ -653,7 +691,9 @@ public: + } + } + +- /// Take rows by dataset row IDs. Results exported as ArrowArrayStream. ++ /// Take rows by dataset row IDs. `out` is caller-owned and its non-null ++ /// `release` must be called exactly once. Deferred iteration/cleanup panics ++ /// are contained by the exported stream guard. + void take_rows(const uint64_t* row_ids, size_t num_row_ids, + const std::vector& columns, + ArrowArrayStream* out) const { +@@ -668,7 +708,8 @@ public: + } + } + +- /// Take all columns by dataset row IDs. ++ /// Take all columns by dataset row IDs with the same stream ownership as ++ /// the overload above. + void take_rows(const uint64_t* row_ids, size_t num_row_ids, + ArrowArrayStream* out) const { + if (lance_dataset_take_rows( +@@ -680,6 +721,23 @@ public: + /// Create a Scanner builder for this dataset. + Scanner scan() const; + ++ /// Prepare a query-specific global BM25 scorer over the committed FTS ++ /// segments of this pinned snapshot. IndexOnly permits unindexed fragments; ++ /// Strict rejects them. Prepared contexts currently require ++ /// `max_fuzzy_distance == 0`. The context can only be attached to scanners ++ /// created from this exact process-local dataset snapshot. ++ FtsQueryContext prepare_fts_query( ++ const std::string& column, ++ const std::string& query, ++ uint32_t max_fuzzy_distance = 0, ++ FtsCoverageMode coverage_mode = FtsCoverageMode::Strict) const { ++ auto* context = lance_dataset_prepare_fts_query( ++ handle_.get(), column.c_str(), query.c_str(), max_fuzzy_distance, ++ static_cast(coverage_mode)); ++ if (!context) check_error(); ++ return FtsQueryContext(context); ++ } ++ + /// Number of fragments in the dataset. + uint64_t fragment_count() const { + uint64_t n = lance_dataset_fragment_count(handle_.get()); +@@ -783,7 +841,7 @@ public: + /// Throws lance::Error with code NotFound if the index does not exist. + uint64_t index_segment_count(const std::string& index_name) const { + uint64_t n = lance_dataset_index_segment_count(handle_.get(), index_name.c_str()); +- if (n == 0 && lance_last_error_code() != LANCE_OK) check_error(); ++ if (lance_last_error_code() != LANCE_OK) check_error(); + return n; + } + +@@ -1127,7 +1185,14 @@ public: + return substrait_filter(bytes.data(), bytes.size()); + } + +- /// Register a callback for scan statistics after successful full exhaustion. ++ /// Add an SQL filter that is combined with the selected primary filter using AND. ++ Scanner& additional_sql_filter(const std::string& filter) { ++ if (lance_scanner_additional_sql_filter(handle_.get(), filter.c_str()) != 0) ++ check_error(); ++ return *this; ++ } ++ ++ /// Register a non-null callback for scan statistics after successful full exhaustion. + /// The registration applies to every stream derived from this scanner, including + /// concurrent streams and streams created after an earlier callback returns. The + /// callback is not guaranteed on error, cancellation, or early release. It may +@@ -1166,12 +1231,20 @@ public: + } + + /// Materialize an independent ArrowArrayStream (blocking). The scanner remains valid. ++ /// `out` is caller-owned; call its non-null `release` callback exactly once. + void to_arrow_stream(ArrowArrayStream* out) { + if (lance_scanner_to_arrow_stream(handle_.get(), out) != 0) + check_error(); + } + +- /// Start an async scan. Callback fires when ArrowArrayStream is ready. ++ /// Start an async scan with a non-null callback. On success, the callback's ++ /// ArrowArrayStream result is library-allocated and must be passed exactly ++ /// once to `lance::scanner_async_stream_free`, which also invokes `release` ++ /// when necessary. The callback normally runs on the dispatcher thread, ++ /// but a rare infrastructure fallback may invoke it on the calling or ++ /// producing thread, possibly before this method returns, so it must be ++ /// thread-safe. Exactly one completion is delivered; callback and non-null ++ /// context storage must remain valid until it returns. + void scan_async(LanceCallback callback, void* ctx) const { + lance_scanner_scan_async(handle_.get(), callback, ctx); + } +@@ -1235,6 +1308,29 @@ public: + return *this; + } + ++ /// Attach a process-local prepared FTS query context. The scanner retains ++ /// shared ownership, so the context object may be destroyed after success. ++ Scanner& fts_query_context(const FtsQueryContext& context) { ++ if (lance_scanner_set_fts_query_context(handle_.get(), context.c_handle()) != 0) ++ check_error(); ++ return *this; ++ } ++ ++ /// Restrict a context-backed FTS query to a segment UUID subset. ++ Scanner& fts_index_segments(const uint8_t* segment_uuids, size_t segment_count) { ++ if (lance_scanner_set_fts_index_segments( ++ handle_.get(), segment_uuids, segment_count) != 0) ++ check_error(); ++ return *this; ++ } ++ ++ Scanner& fts_index_segments( ++ const std::vector>& segment_uuids) { ++ return fts_index_segments( ++ reinterpret_cast(segment_uuids.data()), ++ segment_uuids.size()); ++ } ++ + /// Access the underlying C handle. + LanceScanner* c_handle() { return handle_.get(); } + }; +diff --git a/src/add_columns.rs b/src/add_columns.rs +index 5300c26..a6f30b8 100644 +--- a/src/add_columns.rs ++++ b/src/add_columns.rs +@@ -176,7 +176,7 @@ unsafe fn add_columns_nulls_inner( + let ffi_schema = unsafe { &*schema }; + // Reject an already-released or never-initialised schema before handing it + // to arrow-rs, which would otherwise `assert!` on the NULL `format` field +- // and abort the host process under our `panic = "abort"` profile. Both ++ // and turn predictable invalid input into LANCE_ERR_PANIC. Both + // checks are intentional — `release == NULL` is the canonical Arrow C Data + // Interface "released" sentinel, while `format == NULL` catches a + // zero-initialised or half-built struct that would slip past the release +@@ -187,8 +187,8 @@ unsafe fn add_columns_nulls_inner( + )); + } + // arrow-rs's `FFI_ArrowSchema::format()` does `to_str().expect(..)` on the +- // format pointer; a non-NULL but non-UTF-8 top-level format would abort the +- // process under `panic = "abort"`. Validate it here so a malformed format ++ // format pointer; a non-NULL but non-UTF-8 top-level format would panic in ++ // the guarded FFI boundary. Validate it here so a malformed format + // surfaces as INVALID_ARGUMENT instead. (Child fields are still the caller's + // responsibility — see the doc comment — as walking them would duplicate + // arrow-rs's recursive descent.) +@@ -273,8 +273,8 @@ unsafe fn add_columns_stream_inner( + // Reject a stream missing a mandatory C Data Interface callback *before* + // handing it to arrow-rs. `ArrowArrayStreamReader` only guards against a + // NULL `release`; a NULL `get_schema` or `get_next` would otherwise reach an +- // `unwrap()` deep inside arrow-rs and abort the host process under our +- // `panic = "abort"` profile. We do not require `get_last_error` (the spec ++ // `unwrap()` deep inside arrow-rs and turn predictable invalid input into ++ // LANCE_ERR_PANIC. We do not require `get_last_error` (the spec + // marks it optional): requiring it would not close the abort anyway, since a + // present callback that *returns* NULL at error time hits the same + // `last_error.unwrap()` on arrow-rs's `get_next` error path — a residual +diff --git a/src/alter_columns.rs b/src/alter_columns.rs +index 5f85fae..e2da121 100644 +--- a/src/alter_columns.rs ++++ b/src/alter_columns.rs +@@ -208,8 +208,8 @@ unsafe fn parse_alteration( + let ffi_schema = unsafe { &*entry.data_type }; + // Reject an already-released or never-initialised schema before + // handing it to arrow-rs, which would otherwise `assert!` on the +- // NULL `format` field and abort the host process under our +- // `panic = "abort"` profile. Both checks are intentional: ++ // NULL `format` field and turn predictable invalid input into ++ // LANCE_ERR_PANIC. Both checks are intentional: + // - `release == NULL`: the canonical Arrow CADI "released" sentinel. + // - `format == NULL`: catches a zero-initialised or otherwise + // half-built struct that would slip past the release check. +diff --git a/src/async_dispatcher.rs b/src/async_dispatcher.rs +index 91df74d..0112ed5 100644 +--- a/src/async_dispatcher.rs ++++ b/src/async_dispatcher.rs +@@ -42,7 +42,7 @@ struct Dispatcher { + } + + impl Dispatcher { +- fn new() -> Self { ++ fn new() -> std::io::Result { + let (tx, rx) = mpsc::channel::(); + + std::thread::Builder::new() +@@ -50,48 +50,62 @@ impl Dispatcher { + .spawn(move || { + log::debug!("Lance C dispatcher thread started"); + while let Ok(msg) = rx.recv() { +- // Install the carried error on THIS thread's TLS so the +- // callback's `lance_last_error_*` calls observe it. TLS +- // persists across callbacks on this thread, so a success +- // must explicitly clear: a stale error from an earlier +- // failed callback must never leak into a later one. +- match &msg.error { +- Some((code, message)) => set_last_error(*code, message), +- None => clear_last_error(), +- } +- // Invoke the C callback under catch_unwind, best-effort +- // only (issue #61). The declared callback ABI is +- // `extern "C"` and therefore NON-unwinding — `lance.h` +- // requires callbacks not to panic, and a panic in such a +- // callback aborts at its own boundary before this catch +- // could ever run. The catch exists solely for Rust hosts +- // that pass an `extern "C-unwind"` callback: for them it +- // keeps the dispatcher thread (and with it every later +- // async completion) alive. It is not part of the panic +- // contract and must never be relied on as one. +- let outcome = catch_unwind(AssertUnwindSafe(|| unsafe { +- (msg.callback)(msg.callback_ctx, msg.status, msg.result); +- })); +- if let Err(payload) = outcome { +- log::error!( +- "lance-c dispatcher: unwinding (C-unwind) host callback panicked; contained best-effort: {}", +- panic_payload_message(&*payload) +- ); +- } ++ deliver_message(msg); + } + log::debug!("Lance C dispatcher thread shutting down"); +- }) +- .expect("Failed to spawn lance-c dispatcher thread"); ++ })?; + +- Self { tx } ++ Ok(Self { tx }) + } + +- fn send(&self, msg: DispatcherMessage) { +- let _ = self.tx.send(msg); ++ fn send(&self, msg: DispatcherMessage) -> Result<(), DispatcherMessage> { ++ self.tx.send(msg).map_err(|err| err.0) + } + } + +-static DISPATCHER: LazyLock = LazyLock::new(Dispatcher::new); ++/// Install one completion's TLS state and invoke its callback on the current ++/// thread. Normally that thread is the dispatcher; this is also the fallback ++/// when dispatcher creation or channel delivery fails, preserving the ++/// exactly-once completion contract instead of silently dropping the message. ++fn deliver_message(msg: DispatcherMessage) { ++ match &msg.error { ++ Some((code, message)) => set_last_error(*code, message), ++ None => clear_last_error(), ++ } ++ ++ // Best-effort only (issue #61). A real `extern "C"` callback cannot ++ // unwind; a panic aborts at its own boundary before this catch runs. The ++ // catch only helps Rust hosts that deliberately supply a C-unwind shim. ++ let outcome = catch_unwind(AssertUnwindSafe(|| unsafe { ++ (msg.callback)(msg.callback_ctx, msg.status, msg.result); ++ })); ++ if let Err(payload) = outcome { ++ log::error!( ++ "lance-c dispatcher: unwinding host callback panicked; contained best-effort: {}", ++ panic_payload_message(&*payload) ++ ); ++ } ++} ++ ++fn dispatch_message(dispatcher: Option<&Dispatcher>, msg: DispatcherMessage) { ++ let undelivered = match dispatcher { ++ Some(dispatcher) => match dispatcher.send(msg) { ++ Ok(()) => return, ++ Err(msg) => msg, ++ }, ++ None => msg, ++ }; ++ log::error!("lance-c dispatcher unavailable; invoking async completion on the current thread"); ++ deliver_message(undelivered); ++} ++ ++static DISPATCHER: LazyLock> = LazyLock::new(|| match Dispatcher::new() { ++ Ok(dispatcher) => Some(dispatcher), ++ Err(err) => { ++ log::error!("failed to start lance-c dispatcher thread: {err}"); ++ None ++ } ++}); + + /// Send a completion message to the dispatcher thread. Before invoking the + /// callback, the dispatcher installs `error` on its own thread-local error +@@ -105,13 +119,16 @@ pub(crate) fn dispatch_callback( + result: *mut c_void, + error: Option<(LanceErrorCode, String)>, + ) { +- DISPATCHER.send(DispatcherMessage { +- callback, +- callback_ctx, +- status, +- result, +- error, +- }); ++ dispatch_message( ++ DISPATCHER.as_ref(), ++ DispatcherMessage { ++ callback, ++ callback_ctx, ++ status, ++ result, ++ error, ++ }, ++ ); + } + + #[cfg(test)] +@@ -234,4 +251,53 @@ mod tests { + + unsafe { reclaim(ctx) }; + } ++ ++ #[test] ++ fn unavailable_dispatcher_falls_back_without_dropping_completion() { ++ let (rx, ctx) = probe(); ++ dispatch_message( ++ None, ++ DispatcherMessage { ++ callback: observe, ++ callback_ctx: ctx, ++ status: -1, ++ result: ptr::null_mut(), ++ error: Some(( ++ LanceErrorCode::Internal, ++ "dispatcher unavailable".to_string(), ++ )), ++ }, ++ ); ++ ++ let obs = recv(&rx); ++ assert_eq!(obs.status, -1); ++ assert_eq!(obs.code, LanceErrorCode::Internal); ++ assert_eq!(obs.message.as_deref(), Some("dispatcher unavailable")); ++ unsafe { reclaim(ctx) }; ++ } ++ ++ #[test] ++ fn closed_dispatch_channel_falls_back_without_dropping_completion() { ++ let (tx, dead_rx) = mpsc::channel(); ++ drop(dead_rx); ++ let dispatcher = Dispatcher { tx }; ++ let (rx, ctx) = probe(); ++ ++ dispatch_message( ++ Some(&dispatcher), ++ DispatcherMessage { ++ callback: observe, ++ callback_ctx: ctx, ++ status: 0, ++ result: ptr::dangling_mut::(), ++ error: None, ++ }, ++ ); ++ ++ let obs = recv(&rx); ++ assert_eq!(obs.status, 0); ++ assert!(!obs.result_was_null); ++ assert_eq!(obs.code, LanceErrorCode::Ok); ++ unsafe { reclaim(ctx) }; ++ } + } +diff --git a/src/dataset.rs b/src/dataset.rs +index 9fe63e7..1397b74 100644 +--- a/src/dataset.rs ++++ b/src/dataset.rs +@@ -17,6 +17,7 @@ use lance_core::Result; + use crate::error::{ffi_try, swallow_unwind}; + use crate::helpers; + use crate::runtime::block_on; ++use crate::stream_guard::guarded_ffi_stream_from_reader; + + /// Opaque handle representing an opened Lance dataset. + pub struct LanceDataset { +@@ -151,7 +152,8 @@ unsafe fn open_dataset_inner( + } + + /// Close and free a dataset handle. +-/// Safe to call with NULL. Safe to call multiple times (subsequent calls are no-ops). ++/// Safe to call with NULL. A non-NULL handle must be closed exactly once and ++/// must not be used again afterwards. + /// + /// Best-effort (issue #61): a panic raised while dropping the handle is + /// caught and logged rather than unwinding into the caller, and the +@@ -268,6 +270,10 @@ unsafe fn dataset_schema_inner( + /// - `columns`: NULL-terminated column name array, or NULL for all columns + /// - `out`: pointer to a stack-allocated `ArrowArrayStream` + /// ++/// The already-materialized batch is exported through a guarded reader: ++/// schema conversion is validated before callbacks are exposed, and later ++/// `get_next` / `release` panics are contained at the Arrow C boundary. ++/// + /// Returns 0 on success, -1 on error. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_dataset_take( +@@ -307,7 +313,7 @@ unsafe fn dataset_take_inner( + // Wrap the single RecordBatch as a RecordBatchReader, then export as FFI stream. + let schema = batch.schema(); + let reader = arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema); +- let ffi_stream = FFI_ArrowArrayStream::new(Box::new(reader)); ++ let ffi_stream = guarded_ffi_stream_from_reader(reader)?; + unsafe { + std::ptr::write_unaligned(out, ffi_stream); + } +@@ -326,6 +332,10 @@ unsafe fn dataset_take_inner( + /// to the same dataset snapshot used for this read. Missing or deleted row IDs + /// may be omitted from the result by the upstream Lance implementation. + /// ++/// The already-materialized batch is exported through the same guarded reader ++/// as [`lance_dataset_take`], including schema preflight and deferred callback ++/// panic containment. ++/// + /// Returns 0 on success, -1 on error. + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_dataset_take_rows( +@@ -376,7 +386,7 @@ unsafe fn dataset_take_rows_inner( + // Match lance_dataset_take: export the single RecordBatch as an Arrow stream. + let schema = batch.schema(); + let reader = arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema); +- let ffi_stream = FFI_ArrowArrayStream::new(Box::new(reader)); ++ let ffi_stream = guarded_ffi_stream_from_reader(reader)?; + unsafe { + std::ptr::write_unaligned(out, ffi_stream); + } +@@ -492,10 +502,19 @@ mod tests { + #[test] + fn with_mut_panic_rolls_back_and_handle_stays_usable() { + let (_tmp, handle) = create_test_handle(); ++ let (_replacement_tmp, replacement_handle) = create_test_handle(); ++ let replacement = Dataset::clone(&*replacement_handle.snapshot()); + let uri_before = handle.snapshot().uri().to_string(); ++ assert_ne!(replacement.uri(), uri_before); + + let result = catch_unwind(AssertUnwindSafe(|| { +- handle.with_mut(|_ds| panic!("simulated bug in mutation")) ++ handle.with_mut(|ds| { ++ // Make a visible in-memory mutation before panicking. This ++ // distinguishes clone-execute-swap from mutating the handle's ++ // stored Dataset in place and merely skipping the final swap. ++ *ds = replacement; ++ panic!("simulated bug in mutation") ++ }) + })); + let payload = result.expect_err("panic must escape with_mut unchanged"); + let msg = crate::error::panic_payload_message(&*payload); +diff --git a/src/error.rs b/src/error.rs +index f8158d9..0fe31c4 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -88,6 +88,64 @@ pub fn set_lance_error(err: &lance_core::Error) { + set_last_error(error_code_from_lance(err), err.to_string()); + } + ++/// Why an [`ffi_guard_with`] invocation failed. ++pub(crate) enum FfiFailure { ++ /// The guarded body returned a regular `lance_core::Error`. ++ Lance, ++ /// Something panicked while executing the body or mapping its result. ++ Panic, ++} ++ ++/// Finish a caught FFI panic without leaving panic reporting unguarded. ++/// ++/// A failure while recording the panic or constructing the caller's error ++/// value is itself caught. There is no type-safe value we can manufacture if ++/// that recovery also panics, so the second payload is resumed; this is the ++/// documented double-panic limit of the FFI firewall. ++fn recover_from_ffi_panic( ++ payload: Box, ++ recover: impl FnOnce() -> T, ++) -> T { ++ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { ++ set_last_error( ++ LanceErrorCode::Panic, ++ format!("panic in FFI call: {}", panic_payload_message(&*payload)), ++ ); ++ recover() ++ })) { ++ Ok(value) => value, ++ Err(payload) => std::panic::resume_unwind(payload), ++ } ++} ++ ++/// Run a complete fallible FFI operation under the panic firewall and map any ++/// failure to the ABI-specific return value. ++/// ++/// The guard deliberately includes result mapping, not just `body()`: a ++/// wrapped external error may itself panic from `Display` while ++/// [`set_lance_error`] formats it. Keeping formatting, TLS mutation, and the ++/// error sentinel inside the unwind boundary prevents those secondary panics ++/// from escaping an `extern "C"` entry point. If the sentinel itself panics, ++/// the recovery path records `LanceErrorCode::Panic` and asks for it once more. ++pub(crate) fn ffi_guard_with( ++ body: impl FnOnce() -> lance_core::Result, ++ mut on_failure: impl FnMut(FfiFailure) -> T, ++) -> T { ++ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match body() { ++ Ok(value) => { ++ clear_last_error(); ++ value ++ } ++ Err(err) => { ++ set_lance_error(&err); ++ on_failure(FfiFailure::Lance) ++ } ++ })) { ++ Ok(value) => value, ++ Err(payload) => recover_from_ffi_panic(payload, || on_failure(FfiFailure::Panic)), ++ } ++} ++ + /// Extract a human-readable message from a `catch_unwind` panic payload. + /// + /// `panic!` only ever produces `&str` or `String` payloads; anything else +@@ -182,89 +240,16 @@ pub unsafe extern "C" fn lance_free_string(s: *const c_char) { + /// captured by the `$errval:expr` catch-all. + macro_rules! ffi_try { + ($body:expr, null) => { +- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { +- Ok(Ok(val)) => { +- $crate::error::clear_last_error(); +- val +- } +- Ok(Err(err)) => { +- $crate::error::set_lance_error(&err); +- std::ptr::null_mut() +- } +- Err(payload) => { +- $crate::error::set_last_error( +- $crate::error::LanceErrorCode::Panic, +- format!( +- "panic in FFI call: {}", +- $crate::error::panic_payload_message(&*payload) +- ), +- ); +- std::ptr::null_mut() +- } +- } ++ $crate::error::ffi_guard_with(|| $body, |_| std::ptr::null_mut()) + }; + ($body:expr, neg) => { +- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { +- Ok(Ok(val)) => { +- $crate::error::clear_last_error(); +- val +- } +- Ok(Err(err)) => { +- $crate::error::set_lance_error(&err); +- -1 +- } +- Err(payload) => { +- $crate::error::set_last_error( +- $crate::error::LanceErrorCode::Panic, +- format!( +- "panic in FFI call: {}", +- $crate::error::panic_payload_message(&*payload) +- ), +- ); +- -1 +- } +- } ++ $crate::error::ffi_guard_with(|| $body, |_| -1) + }; + ($body:expr, void) => { +- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { +- Ok(Ok(_)) => { +- $crate::error::clear_last_error(); +- } +- Ok(Err(err)) => { +- $crate::error::set_lance_error(&err); +- } +- Err(payload) => { +- $crate::error::set_last_error( +- $crate::error::LanceErrorCode::Panic, +- format!( +- "panic in FFI call: {}", +- $crate::error::panic_payload_message(&*payload) +- ), +- ); +- } +- } ++ $crate::error::ffi_guard_with(|| $body, |_| ()) + }; + ($body:expr, $errval:expr) => { +- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { +- Ok(Ok(val)) => { +- $crate::error::clear_last_error(); +- val +- } +- Ok(Err(err)) => { +- $crate::error::set_lance_error(&err); +- $errval +- } +- Err(payload) => { +- $crate::error::set_last_error( +- $crate::error::LanceErrorCode::Panic, +- format!( +- "panic in FFI call: {}", +- $crate::error::panic_payload_message(&*payload) +- ), +- ); +- $errval +- } +- } ++ $crate::error::ffi_guard_with(|| $body, |_| $errval) + }; + } + +@@ -275,6 +260,17 @@ mod tests { + use super::*; + use std::ffi::CStr; + ++ #[derive(Debug)] ++ struct PanickingDisplay; ++ ++ impl std::fmt::Display for PanickingDisplay { ++ fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ panic!("simulated panic while formatting an FFI error") ++ } ++ } ++ ++ impl std::error::Error for PanickingDisplay {} ++ + /// Yields a `lance_core::Result` by panicking — the panic is what the + /// `ffi_try!` shapes under test must catch. (The panic hook prints to + /// stderr during these tests; that is expected noise.) +@@ -402,6 +398,48 @@ mod tests { + assert!(msg.contains("bad arg"), "got: {msg}"); + } + ++ #[test] ++ fn ffi_try_catches_panic_while_formatting_lance_error() { ++ let v: u64 = ffi_try!( ++ Err(lance_core::Error::invalid_input_source(Box::new( ++ PanickingDisplay, ++ ))), ++ 0 ++ ); ++ assert_eq!(v, 0); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::Panic); ++ let msg = take_last_error_message().expect("panic must set a message"); ++ assert!( ++ msg.contains("simulated panic while formatting an FFI error"), ++ "got: {msg}" ++ ); ++ } ++ ++ #[test] ++ fn ffi_try_catches_panic_while_building_error_sentinel() { ++ let attempts = std::cell::Cell::new(0); ++ let v: i64 = ffi_try!( ++ Err(lance_core::Error::invalid_input_source("bad arg".into())), ++ { ++ let attempt = attempts.get(); ++ attempts.set(attempt + 1); ++ if attempt == 0 { ++ panic!("simulated panic while building an FFI error sentinel"); ++ } ++ 7 ++ } ++ ); ++ ++ assert_eq!(v, 7); ++ assert_eq!(attempts.get(), 2); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::Panic); ++ let msg = take_last_error_message().expect("panic must set a message"); ++ assert!( ++ msg.contains("simulated panic while building an FFI error sentinel"), ++ "got: {msg}" ++ ); ++ } ++ + #[test] + fn ffi_try_errval_maps_panic_to_errval_and_panic_code() { + // A non-zero sentinel proves the arm returns `$errval` verbatim. +diff --git a/src/fts_query.rs b/src/fts_query.rs +new file mode 100644 +index 0000000..cd194c7 +--- /dev/null ++++ b/src/fts_query.rs +@@ -0,0 +1,324 @@ ++// SPDX-License-Identifier: Apache-2.0 ++// SPDX-FileCopyrightText: Copyright The Lance Authors ++ ++//! Process-local, immutable FTS query context shared by segment-scoped scans. ++ ++use std::collections::HashSet; ++use std::ffi::c_char; ++use std::ptr; ++use std::sync::Arc; ++ ++use futures::future::try_join_all; ++use lance::index::{DatasetIndexExt, DatasetIndexInternalExt}; ++use lance_core::{Error, Result}; ++use lance_index::IndexCriteria; ++use lance_index::metrics::NoOpMetricsCollector; ++use lance_index::scalar::FullTextSearchQuery; ++use lance_index::scalar::inverted::query::{FtsQuery, collect_query_tokens}; ++use lance_index::scalar::inverted::{InvertedIndex, MemBM25Scorer, build_global_bm25_scorer}; ++use lance_table::format::IndexMetadata; ++use uuid::Uuid; ++ ++use crate::dataset::LanceDataset; ++use crate::error::{ffi_try, swallow_unwind}; ++use crate::helpers; ++use crate::runtime::block_on; ++ ++/// Required relationship between the pinned dataset snapshot and its FTS index. ++#[repr(i32)] ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum LanceFtsCoverageMode { ++ /// Every current fragment must be covered by a committed FTS segment. ++ Strict = 0, ++ /// Search and score only documents covered by committed FTS segments. ++ IndexOnly = 1, ++} ++ ++impl TryFrom for LanceFtsCoverageMode { ++ type Error = Error; ++ ++ fn try_from(value: i32) -> Result { ++ match value { ++ 0 => Ok(Self::Strict), ++ 1 => Ok(Self::IndexOnly), ++ _ => Err(Error::invalid_input(format!( ++ "invalid coverage_mode {value}; expected 0 (STRICT) or 1 (INDEX_ONLY)" ++ ))), ++ } ++ } ++} ++ ++/// Rust-owned immutable state behind [`LanceFtsQueryContext`]. ++pub(crate) struct FtsQueryContextInner { ++ pub(crate) dataset: Arc, ++ pub(crate) query: FullTextSearchQuery, ++ pub(crate) segments: Vec, ++ pub(crate) scorer: Arc, ++} ++ ++impl FtsQueryContextInner { ++ pub(crate) fn validate_dataset_identity(&self, dataset: &Arc) -> Result<()> { ++ if !Arc::ptr_eq(&self.dataset, dataset) { ++ return Err(invalid_input(format!( ++ "FTS query context and scanner must originate from the same process-local dataset snapshot; context has uri '{}' version {}, scanner has uri '{}' version {}", ++ self.dataset.uri(), ++ self.dataset.version_id(), ++ dataset.uri(), ++ dataset.version_id() ++ ))); ++ } ++ Ok(()) ++ } ++} ++ ++/// Opaque process-local FTS query context. ++/// ++/// The handle owns an `Arc`, and scanners clone that `Arc` when the context is ++/// attached. It is therefore safe to close the public handle after all scanner ++/// attachments have completed. ++pub struct LanceFtsQueryContext { ++ pub(crate) inner: Arc, ++} ++ ++fn invalid_input(message: impl Into) -> Error { ++ Error::invalid_input(message.into()) ++} ++ ++async fn prepare_fts_query_context( ++ dataset: Arc, ++ column: String, ++ query_text: String, ++ coverage_mode: LanceFtsCoverageMode, ++) -> Result { ++ let logical_index = dataset ++ .load_scalar_index(IndexCriteria::default().for_column(&column).supports_fts()) ++ .await? ++ .ok_or_else(|| { ++ invalid_input(format!( ++ "no committed FTS index exists for column '{column}' in dataset version {}", ++ dataset.version_id() ++ )) ++ })?; ++ let segments = dataset.load_indices_by_name(&logical_index.name).await?; ++ if segments.is_empty() { ++ return Err(invalid_input(format!( ++ "FTS index for column '{column}' has no committed segments in dataset version {}", ++ dataset.version_id() ++ ))); ++ } ++ ++ let expected_fields = &segments[0].fields; ++ if let Some(segment) = segments ++ .iter() ++ .find(|segment| &segment.fields != expected_fields) ++ { ++ return Err(invalid_input(format!( ++ "FTS index '{}' has inconsistent fields across segments; segment {} has fields {:?}, expected {:?}", ++ logical_index.name, segment.uuid, segment.fields, expected_fields ++ ))); ++ } ++ ++ let current_fragment_ids: HashSet = dataset ++ .get_fragments() ++ .into_iter() ++ .map(|fragment| { ++ u32::try_from(fragment.id()).map_err(|_| { ++ invalid_input(format!( ++ "fragment id {} exceeds the u32 index metadata range", ++ fragment.id() ++ )) ++ }) ++ }) ++ .collect::>()?; ++ ++ let mut indexed_fragment_ids = HashSet::new(); ++ for segment in &segments { ++ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { ++ invalid_input(format!( ++ "FTS segment {} for column '{column}' has unknown fragment coverage", ++ segment.uuid ++ )) ++ })?; ++ indexed_fragment_ids.extend( ++ fragment_bitmap ++ .iter() ++ .filter(|fragment_id| current_fragment_ids.contains(fragment_id)), ++ ); ++ } ++ let mut unindexed_fragment_ids: Vec = current_fragment_ids ++ .difference(&indexed_fragment_ids) ++ .copied() ++ .collect(); ++ unindexed_fragment_ids.sort_unstable(); ++ ++ if coverage_mode == LanceFtsCoverageMode::Strict && !unindexed_fragment_ids.is_empty() { ++ return Err(invalid_input(format!( ++ "coverage_mode=STRICT requires every fragment in dataset version {} to be indexed; column '{column}' has {} unindexed fragments: {:?}", ++ dataset.version_id(), ++ unindexed_fragment_ids.len(), ++ unindexed_fragment_ids ++ ))); ++ } ++ ++ let indices: Vec> = try_join_all(segments.iter().map(|segment| { ++ let dataset = Arc::clone(&dataset); ++ let column = column.clone(); ++ async move { ++ let index = dataset ++ .open_scalar_index(&column, &segment.uuid, &NoOpMetricsCollector) ++ .await?; ++ let inverted = index ++ .as_any() ++ .downcast_ref::() ++ .ok_or_else(|| { ++ invalid_input(format!( ++ "index segment {} for column '{column}' is not an inverted index", ++ segment.uuid ++ )) ++ })?; ++ Ok::<_, Error>(Arc::new(inverted.clone())) ++ } ++ })) ++ .await?; ++ ++ let expected_params = indices[0].params(); ++ if let Some((position, _)) = indices ++ .iter() ++ .enumerate() ++ .find(|(_, index)| index.params() != expected_params) ++ { ++ return Err(invalid_input(format!( ++ "FTS index '{}' has inconsistent inverted index parameters; segment {} differs from segment {}", ++ logical_index.name, segments[position].uuid, segments[0].uuid ++ ))); ++ } ++ ++ let query = FullTextSearchQuery::new(query_text).with_column(column.clone())?; ++ let match_query = match &query.query { ++ FtsQuery::Match(query) => query, ++ _ => { ++ return Err(Error::internal( ++ "prepared FTS query unexpectedly produced a non-Match query".to_string(), ++ )); ++ } ++ }; ++ let mut tokenizer = indices[0].tokenizer(); ++ let query_tokens = collect_query_tokens(&match_query.terms, &mut tokenizer); ++ let params = query ++ .params() ++ .with_fuzziness(match_query.fuzziness) ++ .with_max_expansions(match_query.max_expansions) ++ .with_prefix_length(match_query.prefix_length); ++ let scorer = Arc::new(build_global_bm25_scorer(&indices, &query_tokens, ¶ms).await?); ++ ++ Ok(FtsQueryContextInner { ++ dataset, ++ query, ++ segments, ++ scorer, ++ }) ++} ++ ++/// Prepare a process-local global BM25 scorer and the committed segment list ++/// for one single-column Match query against the dataset's pinned snapshot. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_dataset_prepare_fts_query( ++ dataset: *const LanceDataset, ++ column: *const c_char, ++ query: *const c_char, ++ max_fuzzy_distance: u32, ++ coverage_mode: i32, ++) -> *mut LanceFtsQueryContext { ++ ffi_try!( ++ unsafe { ++ prepare_fts_query_inner(dataset, column, query, max_fuzzy_distance, coverage_mode) ++ }, ++ null ++ ) ++} ++ ++unsafe fn prepare_fts_query_inner( ++ dataset: *const LanceDataset, ++ column: *const c_char, ++ query: *const c_char, ++ max_fuzzy_distance: u32, ++ coverage_mode: i32, ++) -> Result<*mut LanceFtsQueryContext> { ++ if dataset.is_null() || column.is_null() || query.is_null() { ++ return Err(invalid_input("dataset, column, and query must not be NULL")); ++ } ++ let column = unsafe { helpers::parse_c_string(column)? } ++ .filter(|value| !value.is_empty()) ++ .ok_or_else(|| invalid_input("column must not be empty"))? ++ .to_string(); ++ let query = unsafe { helpers::parse_c_string(query)? } ++ .filter(|value| !value.is_empty()) ++ .ok_or_else(|| invalid_input("query must not be empty"))? ++ .to_string(); ++ let coverage_mode = LanceFtsCoverageMode::try_from(coverage_mode)?; ++ if max_fuzzy_distance != 0 { ++ return Err(invalid_input(format!( ++ "max_fuzzy_distance must be 0 for prepared FTS query contexts, got {max_fuzzy_distance}; fuzzy queries require a canonical prepared BM25 vocabulary" ++ ))); ++ } ++ let snapshot = unsafe { &*dataset }.snapshot(); ++ let inner = block_on(prepare_fts_query_context( ++ snapshot, ++ column, ++ query, ++ coverage_mode, ++ ))?; ++ Ok(Box::into_raw(Box::new(LanceFtsQueryContext { ++ inner: Arc::new(inner), ++ }))) ++} ++ ++/// Close a context handle. NULL-safe. Scanners that already attached the ++/// context retain their own shared reference. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_fts_query_context_close(context: *mut LanceFtsQueryContext) { ++ if !context.is_null() { ++ swallow_unwind("lance_fts_query_context_close", || unsafe { ++ drop(Box::from_raw(context)); ++ }); ++ } ++} ++ ++pub(crate) unsafe fn clone_context( ++ context: *const LanceFtsQueryContext, ++) -> Result> { ++ if context.is_null() { ++ return Err(invalid_input("context must not be NULL")); ++ } ++ Ok(Arc::clone(&unsafe { &*context }.inner)) ++} ++ ++pub(crate) fn parse_segment_uuids(segment_uuids: *const u8, len: usize) -> Result> { ++ if segment_uuids.is_null() && len > 0 { ++ return Err(invalid_input( ++ "segment_uuids is NULL but len is greater than 0", ++ )); ++ } ++ if len > isize::MAX as usize / 16 { ++ return Err(invalid_input(format!( ++ "segment UUID count {len} exceeds the maximum addressable byte slice length" ++ ))); ++ } ++ let mut uuids = Vec::with_capacity(len); ++ for position in 0..len { ++ let mut bytes = [0_u8; 16]; ++ unsafe { ++ ptr::copy_nonoverlapping(segment_uuids.add(position * 16), bytes.as_mut_ptr(), 16); ++ } ++ uuids.push(Uuid::from_bytes(bytes)); ++ } ++ let unique: HashSet = uuids.iter().copied().collect(); ++ if unique.len() != uuids.len() { ++ return Err(invalid_input(format!( ++ "segment_uuids contains duplicate UUIDs; len={}, unique={}", ++ uuids.len(), ++ unique.len() ++ ))); ++ } ++ Ok(uuids) ++} +diff --git a/src/index_segment.rs b/src/index_segment.rs +index 9ffc8d0..a46c4f3 100644 +--- a/src/index_segment.rs ++++ b/src/index_segment.rs +@@ -22,7 +22,7 @@ use prost::Message; + use uuid::Uuid; + + use crate::dataset::LanceDataset; +-use crate::error::{LanceErrorCode, clear_last_error, ffi_try, set_last_error}; ++use crate::error::{ffi_try, swallow_unwind}; + use crate::helpers; + use crate::index::{ + LanceMetricType, LanceScalarIndexType, LanceVectorIndexParams, LanceVectorIndexType, +@@ -1042,7 +1042,9 @@ pub unsafe extern "C" fn lance_free_bytes(bytes: *mut u8) { + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_index_segment_builder_free(builder: *mut LanceIndexSegmentBuilder) { + if !builder.is_null() { +- unsafe { drop(Box::from_raw(builder)) }; ++ swallow_unwind("lance_index_segment_builder_free", || unsafe { ++ drop(Box::from_raw(builder)); ++ }); + } + } + +@@ -1155,12 +1157,15 @@ unsafe fn metadata_uuid_inner( + pub unsafe extern "C" fn lance_index_segment_metadata_name( + metadata: *const LanceIndexSegmentMetadata, + ) -> *const c_char { +- if metadata.is_null() { +- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); +- return ptr::null(); +- } +- clear_last_error(); +- unsafe { (*metadata).name.as_ptr() } ++ ffi_try!( ++ (|| -> Result<*const c_char> { ++ if metadata.is_null() { ++ return Err(invalid_input("metadata is NULL")); ++ } ++ Ok(unsafe { (*metadata).name.as_ptr() }) ++ })(), ++ ptr::null() ++ ) + } + + /// Return the dataset version recorded in the metadata. +@@ -1168,12 +1173,15 @@ pub unsafe extern "C" fn lance_index_segment_metadata_name( + pub unsafe extern "C" fn lance_index_segment_metadata_dataset_version( + metadata: *const LanceIndexSegmentMetadata, + ) -> u64 { +- if metadata.is_null() { +- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); +- return 0; +- } +- clear_last_error(); +- unsafe { (*metadata).metadata.dataset_version } ++ ffi_try!( ++ (|| -> Result { ++ if metadata.is_null() { ++ return Err(invalid_input("metadata is NULL")); ++ } ++ Ok(unsafe { (*metadata).metadata.dataset_version }) ++ })(), ++ 0 ++ ) + } + + /// Return the physical index version recorded in the metadata. +@@ -1181,12 +1189,15 @@ pub unsafe extern "C" fn lance_index_segment_metadata_dataset_version( + pub unsafe extern "C" fn lance_index_segment_metadata_index_version( + metadata: *const LanceIndexSegmentMetadata, + ) -> i32 { +- if metadata.is_null() { +- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); +- return -1; +- } +- clear_last_error(); +- unsafe { (*metadata).metadata.index_version } ++ ffi_try!( ++ (|| -> Result { ++ if metadata.is_null() { ++ return Err(invalid_input("metadata is NULL")); ++ } ++ Ok(unsafe { (*metadata).metadata.index_version }) ++ })(), ++ neg ++ ) + } + + /// Return the concrete scalar/vector index enum value, or -1 on error. +@@ -1194,16 +1205,7 @@ pub unsafe extern "C" fn lance_index_segment_metadata_index_version( + pub unsafe extern "C" fn lance_index_segment_metadata_index_type( + metadata: *const LanceIndexSegmentMetadata, + ) -> i32 { +- match unsafe { metadata_index_type_inner(metadata) } { +- Ok(index_type) => { +- clear_last_error(); +- index_type +- } +- Err(error) => { +- crate::error::set_lance_error(&error); +- -1 +- } +- } ++ ffi_try!(unsafe { metadata_index_type_inner(metadata) }, neg) + } + + unsafe fn metadata_index_type_inner(metadata: *const LanceIndexSegmentMetadata) -> Result { +@@ -1258,19 +1260,20 @@ unsafe fn metadata_index_type_inner(metadata: *const LanceIndexSegmentMetadata) + pub unsafe extern "C" fn lance_index_segment_metadata_index_details_type_url( + metadata: *const LanceIndexSegmentMetadata, + ) -> *const c_char { +- if metadata.is_null() { +- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); +- return ptr::null(); +- } +- let Some(type_url) = (unsafe { &(*metadata).index_details_type_url }) else { +- set_last_error( +- LanceErrorCode::NotFound, +- "index metadata does not contain index_details", +- ); +- return ptr::null(); +- }; +- clear_last_error(); +- type_url.as_ptr() ++ ffi_try!( ++ (|| -> Result<*const c_char> { ++ if metadata.is_null() { ++ return Err(invalid_input("metadata is NULL")); ++ } ++ let type_url = unsafe { &(*metadata).index_details_type_url } ++ .as_ref() ++ .ok_or_else(|| { ++ Error::index_not_found("index metadata does not contain index_details") ++ })?; ++ Ok(type_url.as_ptr()) ++ })(), ++ ptr::null() ++ ) + } + + /// Return the number of indexed field IDs. +@@ -1278,12 +1281,15 @@ pub unsafe extern "C" fn lance_index_segment_metadata_index_details_type_url( + pub unsafe extern "C" fn lance_index_segment_metadata_field_count( + metadata: *const LanceIndexSegmentMetadata, + ) -> usize { +- if metadata.is_null() { +- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); +- return 0; +- } +- clear_last_error(); +- unsafe { (*metadata).metadata.fields.len() } ++ ffi_try!( ++ (|| -> Result { ++ if metadata.is_null() { ++ return Err(invalid_input("metadata is NULL")); ++ } ++ Ok(unsafe { (*metadata).metadata.fields.len() }) ++ })(), ++ 0 ++ ) + } + + /// Copy indexed field IDs in metadata order. +@@ -1334,12 +1340,15 @@ unsafe fn metadata_field_ids_inner( + pub unsafe extern "C" fn lance_index_segment_metadata_fragment_count( + metadata: *const LanceIndexSegmentMetadata, + ) -> usize { +- if metadata.is_null() { +- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); +- return 0; +- } +- clear_last_error(); +- unsafe { (*metadata).fragment_ids.len() } ++ ffi_try!( ++ (|| -> Result { ++ if metadata.is_null() { ++ return Err(invalid_input("metadata is NULL")); ++ } ++ Ok(unsafe { (*metadata).fragment_ids.len() }) ++ })(), ++ 0 ++ ) + } + + /// Copy covered fragment IDs in ascending order. +@@ -1393,6 +1402,8 @@ pub unsafe extern "C" fn lance_index_segment_metadata_free( + metadata: *mut LanceIndexSegmentMetadata, + ) { + if !metadata.is_null() { +- unsafe { drop(Box::from_raw(metadata)) }; ++ swallow_unwind("lance_index_segment_metadata_free", || unsafe { ++ drop(Box::from_raw(metadata)); ++ }); + } + } +diff --git a/src/lib.rs b/src/lib.rs +index ed9cfe1..4d54641 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -15,6 +15,11 @@ + //! - The caller is responsible for freeing returned strings with `lance_free_string()`. + #![allow(clippy::missing_safety_doc)] + ++#[cfg(not(panic = "unwind"))] ++compile_error!( ++ "lance-c requires panic=\"unwind\" so its C ABI panic firewall can honor LANCE_ERR_PANIC" ++); ++ + mod add_columns; + mod alter_columns; + mod async_dispatcher; +@@ -26,6 +31,7 @@ mod delete; + mod drop_columns; + mod error; + mod fragment_writer; ++mod fts_query; + mod helpers; + mod index; + mod index_model; +@@ -52,6 +58,7 @@ pub use error::{ + LanceErrorCode, lance_free_string, lance_last_error_code, lance_last_error_message, + }; + pub use fragment_writer::*; ++pub use fts_query::*; + pub use index::*; + pub use index_model::*; + pub use index_segment::*; +diff --git a/src/scanner.rs b/src/scanner.rs +index ef9d290..7110111 100644 +--- a/src/scanner.rs ++++ b/src/scanner.rs +@@ -6,28 +6,37 @@ + use std::ffi::{c_char, c_void}; + use std::pin::Pin; + use std::ptr; +-use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; ++use std::sync::{Arc, Condvar, Mutex, Weak}; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + use arrow::ffi_stream::FFI_ArrowArrayStream; +-use arrow_schema::SchemaRef; ++use arrow_schema::{Schema as ArrowSchema, SchemaRef}; ++use datafusion::physical_plan::ExecutionPlan; + use futures::{FutureExt, Stream, StreamExt}; + use lance::Dataset; + use lance::dataset::scanner::{ + DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts, + }; ++use lance::io::exec::fts::MatchQueryExec; + use lance_core::Result; ++use lance_datafusion::exec::{LanceExecutionOptions, get_session_context}; ++use lance_datafusion::planner::Planner; ++use lance_datafusion::substrait::parse_substrait; + use lance_index::scalar::FullTextSearchQuery; + use lance_io::stream::RecordBatchStream; ++use lance_table::format::IndexMetadata; + use uuid::Uuid; + + use crate::async_dispatcher::{self, LanceCallback}; + use crate::batch::LanceBatch; + use crate::dataset::LanceDataset; + use crate::error::{ +- LanceErrorCode, clear_last_error, error_code_from_lance, ffi_try, panic_payload_message, +- set_lance_error, set_last_error, swallow_unwind, ++ FfiFailure, LanceErrorCode, clear_last_error, error_code_from_lance, ffi_guard_with, ffi_try, ++ panic_payload_message, set_lance_error, set_last_error, swallow_unwind, ++}; ++use crate::fts_query::{ ++ FtsQueryContextInner, LanceFtsQueryContext, clone_context, parse_segment_uuids, + }; + use crate::helpers; + use crate::runtime::{RT, block_on}; +@@ -50,6 +59,7 @@ pub struct LanceScanner { + columns: Option>, + filter: Option, + substrait_filter: Option>, ++ additional_sql_filters: Vec, + limit: Option, + offset: Option, + batch_size: Option, +@@ -64,13 +74,20 @@ pub struct LanceScanner { + use_index: Option, + prefilter: bool, + fts_query: Option, +- // Set when a panic is caught in a stateful stream operation (issue #61): ++ fts_context: Option>, ++ fts_index_segments: Option>, ++ // Set when a panic is caught in any operation on this scanner (issue #61): + // once poisoned, every later `lance_scanner_*` call on this handle (except + // `lance_scanner_close`, which must always free memory) fails with + // `LANCE_ERR_PANIC`. Behind an `Arc` so the exported-stream wrapper and + // the spawned async task can poison the handle from outside this call + // frame via `poison_flag()`. + poisoned: Arc, ++ // Every RawWaker handed to the poll stream registers here. Close retires ++ // the registry before dropping the stream: pending callbacks are ++ // cancelled and callbacks already in progress are allowed to quiesce ++ // before the caller may destroy callback_ctx. ++ poll_wakers: PollWakerRegistry, + scan_statistics_callback: Option, + scan_started: AtomicBool, + // Materialized on first iteration call +@@ -111,6 +128,7 @@ impl LanceScanner { + columns: None, + filter: None, + substrait_filter: None, ++ additional_sql_filters: Vec::new(), + limit: None, + offset: None, + batch_size: None, +@@ -125,7 +143,10 @@ impl LanceScanner { + use_index: None, + prefilter: false, + fts_query: None, ++ fts_context: None, ++ fts_index_segments: None, + poisoned: Arc::new(AtomicBool::new(false)), ++ poll_wakers: PollWakerRegistry::default(), + scan_statistics_callback: None, + scan_started: AtomicBool::new(false), + stream: None, +@@ -161,86 +182,58 @@ impl LanceScanner { + Ok(()) + } + +- /// Build the underlying Scanner and open a stream. +- fn materialize_stream(&mut self) -> Result<()> { +- self.scan_started.store(true, Ordering::Release); +- let mut scanner = self.dataset.scan(); +- if let Some(cols) = &self.columns { +- scanner.project(cols)?; +- } +- // Substrait filter takes precedence over SQL filter when both are set. +- if let Some(bytes) = &self.substrait_filter { +- scanner.filter_substrait(bytes)?; +- } else if let Some(filter) = &self.filter { +- scanner.filter(filter)?; +- } +- if self.limit.is_some() || self.offset.is_some() { +- scanner.limit(self.limit, self.offset)?; +- } +- if let Some(bs) = self.batch_size { +- scanner.batch_size(bs); +- } +- if self.with_row_id { +- scanner.with_row_id(); +- } +- self.apply_fragment_filter(&mut scanner)?; +- if self.index_segments.is_some() && self.nearest.is_none() { +- return Err(lance_core::Error::invalid_input_source( +- "index_segments requires nearest() to be configured".into(), +- )); +- } +- // Lance validates fragment-scoped nearest searches when nearest() is +- // configured. Such searches are supported when the fragment scan is +- // the input to a prefilter, so this flag must be set first. +- if self.prefilter { +- scanner.prefilter(true); +- } +- if let Some(n) = &self.nearest { +- scanner.nearest(&n.column, n.query.as_ref(), n.k as usize)?; +- if let Some(np) = self.nprobes { +- scanner.nprobes(np as usize); +- } +- if let Some(rf) = self.refine_factor { +- scanner.refine(rf); +- } +- if let Some(ef) = self.ef { +- scanner.ef(ef as usize); +- } +- if let Some(m) = self.metric_override { +- scanner.distance_metric(m.to_distance()); +- } +- if let Some(ui) = self.use_index { +- scanner.use_index(ui); +- } +- if let Some(segments) = &self.index_segments { +- scanner.with_index_segments(segments.clone())?; ++ fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { ++ if self.additional_sql_filters.is_empty() { ++ if let Some(substrait) = &self.substrait_filter { ++ scanner.filter_substrait(substrait)?; ++ } else if let Some(sql) = &self.filter { ++ scanner.filter(sql)?; + } ++ return Ok(()); + } +- if let Some(fts) = &self.fts_query { +- scanner.full_text_search(fts.clone())?; +- } +- if let Some(callback) = &self.scan_statistics_callback { +- scanner.scan_stats_callback(callback.clone()); ++ ++ let schema = Arc::new(ArrowSchema::from(self.dataset.schema())); ++ let planner = Planner::new(Arc::clone(&schema)); ++ let mut combined = if let Some(substrait) = &self.substrait_filter { ++ let context = get_session_context(&LanceExecutionOptions::default()); ++ Some( ++ parse_substrait(substrait, schema, &context.state()) ++ .now_or_never() ++ .expect("Substrait filter parsing must complete synchronously")?, ++ ) ++ } else if let Some(sql) = &self.filter { ++ Some(planner.parse_filter(sql)?) ++ } else { ++ None ++ }; ++ for sql in &self.additional_sql_filters { ++ let sql = planner.parse_filter(sql)?; ++ combined = Some(match combined { ++ Some(existing) => existing.and(sql), ++ None => sql, ++ }); + } +- let stream = block_on(scanner.try_into_stream())?; ++ scanner.filter_expr(planner.optimize_expr(combined.expect("additional filter exists"))?); ++ Ok(()) ++ } ++ ++ /// Build the underlying Scanner and open a stream. ++ fn materialize_stream(&mut self) -> Result<()> { ++ let prepared_scanner = self.build_scanner()?; ++ let stream = block_on(prepared_scanner.try_into_stream())?; + self.schema = Some(stream.schema()); + self.stream = Some(Box::pin(stream)); + Ok(()) + } + + /// Build a Scanner (without materializing) and return it. +- fn build_scanner(&self) -> Result { ++ fn build_scanner(&self) -> Result { + self.scan_started.store(true, Ordering::Release); + let mut scanner = self.dataset.scan(); + if let Some(cols) = &self.columns { + scanner.project(cols)?; + } +- // Substrait filter takes precedence over SQL filter when both are set. +- if let Some(bytes) = &self.substrait_filter { +- scanner.filter_substrait(bytes)?; +- } else if let Some(filter) = &self.filter { +- scanner.filter(filter)?; +- } ++ self.apply_filter(&mut scanner)?; + if self.limit.is_some() || self.offset.is_some() { + scanner.limit(self.limit, self.offset)?; + } +@@ -256,6 +249,16 @@ impl LanceScanner { + "index_segments requires nearest() to be configured".into(), + )); + } ++ if self.fts_index_segments.is_some() && self.fts_context.is_none() { ++ return Err(lance_core::Error::invalid_input_source( ++ "fts_index_segments requires an FTS query context".into(), ++ )); ++ } ++ if self.fts_context.is_some() && self.fragment_ids.is_some() { ++ return Err(lance_core::Error::invalid_input_source( ++ "fragment_ids cannot be combined with an FTS query context; split the query by FTS index segment UUID instead".into(), ++ )); ++ } + // nearest() checks the current prefilter setting before accepting a + // fragment-scoped search. Enable it before installing the query. + if self.prefilter { +@@ -285,11 +288,141 @@ impl LanceScanner { + if let Some(fts) = &self.fts_query { + scanner.full_text_search(fts.clone())?; + } ++ let distributed_fts = if let Some(context) = &self.fts_context { ++ context.validate_dataset_identity(&self.dataset)?; ++ let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; ++ scanner.full_text_search(context.query.clone())?; ++ // Both STRICT and INDEX_ONLY context scans must use only the ++ // committed segments pinned in the context. In STRICT mode all ++ // current fragments were already proven covered during prepare. ++ scanner.fast_search(); ++ Some(PreparedFtsExecution { ++ context: Arc::clone(context), ++ segments, ++ batch_size: self.batch_size, ++ scan_statistics_callback: self.scan_statistics_callback.clone(), ++ }) ++ } else { ++ None ++ }; + if let Some(callback) = &self.scan_statistics_callback { + scanner.scan_stats_callback(callback.clone()); + } +- Ok(scanner) ++ Ok(PreparedScanner { ++ scanner, ++ distributed_fts, ++ }) ++ } ++} ++ ++struct PreparedFtsExecution { ++ context: Arc, ++ segments: Vec, ++ batch_size: Option, ++ scan_statistics_callback: Option, ++} ++ ++struct PreparedScanner { ++ scanner: lance::dataset::scanner::Scanner, ++ distributed_fts: Option, ++} ++ ++impl PreparedScanner { ++ async fn try_into_stream(self) -> Result { ++ let Some(distributed_fts) = self.distributed_fts else { ++ return self.scanner.try_into_stream().await; ++ }; ++ let plan = self.scanner.create_plan().await?; ++ let (plan, replaced) = replace_match_query_exec( ++ plan, ++ &distributed_fts.segments, ++ &distributed_fts.context.scorer, ++ )?; ++ if replaced != 1 { ++ return Err(lance_core::Error::internal(format!( ++ "expected exactly one MatchQueryExec in prepared FTS plan, replaced {replaced}" ++ ))); ++ } ++ let stream = lance_datafusion::exec::execute_plan( ++ plan, ++ lance_datafusion::exec::LanceExecutionOptions { ++ batch_size: distributed_fts.batch_size, ++ execution_stats_callback: distributed_fts.scan_statistics_callback, ++ ..Default::default() ++ }, ++ )?; ++ Ok(DatasetRecordBatchStream::new(stream)) ++ } ++} ++ ++fn select_fts_segments( ++ context: &FtsQueryContextInner, ++ selected_uuids: Option<&[Uuid]>, ++) -> Result> { ++ let Some(selected_uuids) = selected_uuids else { ++ return Ok(context.segments.clone()); ++ }; ++ let mut selected = Vec::with_capacity(selected_uuids.len()); ++ for uuid in selected_uuids { ++ let segment = context ++ .segments ++ .iter() ++ .find(|segment| segment.uuid == *uuid) ++ .ok_or_else(|| { ++ lance_core::Error::invalid_input_source( ++ format!( ++ "FTS segment UUID {uuid} is not present in the attached query context for dataset version {}", ++ context.dataset.version_id() ++ ) ++ .into(), ++ ) ++ })?; ++ selected.push(segment.clone()); ++ } ++ if selected.is_empty() { ++ return Err(lance_core::Error::invalid_input_source( ++ "FTS segment subset must contain at least one UUID".into(), ++ )); ++ } ++ Ok(selected) ++} ++ ++fn replace_match_query_exec( ++ plan: Arc, ++ segments: &[IndexMetadata], ++ scorer: &Arc, ++) -> Result<(Arc, usize)> { ++ let children = plan.children(); ++ let mut replaced = 0; ++ let rebuilt = if children.is_empty() { ++ plan ++ } else { ++ let mut new_children = Vec::with_capacity(children.len()); ++ for child in children { ++ let (new_child, child_replaced) = ++ replace_match_query_exec(Arc::clone(child), segments, scorer)?; ++ new_children.push(new_child); ++ replaced += child_replaced; ++ } ++ plan.with_new_children(new_children).map_err(|error| { ++ lance_core::Error::internal(format!( ++ "failed to rebuild FTS execution plan children: {error}" ++ )) ++ })? ++ }; ++ ++ if let Some(exec) = rebuilt.downcast_ref::() { ++ let replacement = MatchQueryExec::new_with_segments( ++ Arc::clone(exec.dataset()), ++ exec.query().clone(), ++ exec.params().clone(), ++ exec.prefilter_source().clone(), ++ segments.to_vec(), ++ ) ++ .with_base_scorer(Arc::clone(scorer)); ++ return Ok((Arc::new(replacement), replaced + 1)); + } ++ Ok((rebuilt, replaced)) + } + + /// Type of a dynamically named scan metric. +@@ -442,6 +575,26 @@ macro_rules! scanner_poison_check { + }; + } + ++/// Run a scanner configuration call through the common FFI guard and poison ++/// the handle if that call catches a panic. Regular Lance errors remain ++/// recoverable and do not poison the builder. ++macro_rules! scanner_ffi_try { ++ ($scanner:expr, $body:expr $(,)?) => {{ ++ let scanner_ptr = $scanner; ++ ffi_guard_with( ++ || $body, ++ |failure| { ++ if matches!(failure, FfiFailure::Panic) && !scanner_ptr.is_null() { ++ unsafe { &*scanner_ptr } ++ .poison_flag() ++ .store(true, Ordering::SeqCst); ++ } ++ -1 ++ }, ++ ) ++ }}; ++} ++ + // --------------------------------------------------------------------------- + // Scanner lifecycle + builder + // --------------------------------------------------------------------------- +@@ -486,7 +639,7 @@ unsafe fn scanner_new_inner( + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_scanner_set_limit(scanner: *mut LanceScanner, limit: i64) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!(unsafe { scanner_set_limit_inner(scanner, limit) }, neg) ++ scanner_ffi_try!(scanner, unsafe { scanner_set_limit_inner(scanner, limit) }) + } + + unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Result { +@@ -504,7 +657,9 @@ unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Res + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_scanner_set_offset(scanner: *mut LanceScanner, offset: i64) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!(unsafe { scanner_set_offset_inner(scanner, offset) }, neg) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_offset_inner(scanner, offset) ++ }) + } + + unsafe fn scanner_set_offset_inner(scanner: *mut LanceScanner, offset: i64) -> Result { +@@ -525,10 +680,9 @@ pub unsafe extern "C" fn lance_scanner_set_batch_size( + batch_size: i64, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { scanner_set_batch_size_inner(scanner, batch_size) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_batch_size_inner(scanner, batch_size) ++ }) + } + + unsafe fn scanner_set_batch_size_inner(scanner: *mut LanceScanner, batch_size: i64) -> Result { +@@ -549,7 +703,9 @@ pub unsafe extern "C" fn lance_scanner_with_row_id( + enable: bool, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!(unsafe { scanner_with_row_id_inner(scanner, enable) }, neg) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_with_row_id_inner(scanner, enable) ++ }) + } + + unsafe fn scanner_with_row_id_inner(scanner: *mut LanceScanner, enable: bool) -> Result { +@@ -574,10 +730,9 @@ pub unsafe extern "C" fn lance_scanner_set_fragment_ids( + len: usize, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { scanner_set_fragment_ids_inner(scanner, ids, len) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_fragment_ids_inner(scanner, ids, len) ++ }) + } + + unsafe fn scanner_set_fragment_ids_inner( +@@ -631,10 +786,9 @@ pub unsafe extern "C" fn lance_scanner_set_substrait_filter( + len: usize, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { scanner_set_substrait_filter_inner(scanner, bytes, len) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_substrait_filter_inner(scanner, bytes, len) ++ }) + } + + unsafe fn scanner_set_substrait_filter_inner( +@@ -663,6 +817,50 @@ unsafe fn scanner_set_substrait_filter_inner( + Ok(0) + } + ++/// Add an SQL filter that is combined with the scanner's selected primary filter using AND. ++/// ++/// The primary filter is the Substrait filter when one is set, otherwise it is the SQL filter ++/// passed to `lance_scanner_new`. Multiple additional SQL filters are also combined using AND. ++/// This must be called before the scan starts. The string is copied into the scanner. ++/// ++/// Returns 0 on success, -1 on error (check `lance_last_error_*`). ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_scanner_additional_sql_filter( ++ scanner: *mut LanceScanner, ++ filter: *const c_char, ++) -> i32 { ++ scanner_poison_check!(scanner, -1); ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_additional_sql_filter_inner(scanner, filter) ++ }) ++} ++ ++unsafe fn scanner_additional_sql_filter_inner( ++ scanner: *mut LanceScanner, ++ filter: *const c_char, ++) -> Result { ++ if scanner.is_null() { ++ return Err(lance_core::Error::invalid_input_source( ++ "scanner is NULL".into(), ++ )); ++ } ++ let filter = unsafe { helpers::parse_c_string(filter)? } ++ .ok_or_else(|| lance_core::Error::invalid_input_source("filter must not be NULL".into()))?; ++ if filter.is_empty() { ++ return Err(lance_core::Error::invalid_input_source( ++ "additional SQL filter must be non-empty".into(), ++ )); ++ } ++ let scanner = unsafe { &mut *scanner }; ++ if scanner.scan_started.load(Ordering::Acquire) { ++ return Err(lance_core::Error::invalid_input_source( ++ "additional SQL filter must be set before the scan starts".into(), ++ )); ++ } ++ scanner.additional_sql_filters.push(filter.to_string()); ++ Ok(0) ++} ++ + /// Register a callback that receives execution statistics after the scan stream + /// is fully consumed to EOF. + /// +@@ -693,10 +891,9 @@ pub unsafe extern "C" fn lance_scanner_set_statistics_callback( + callback_ctx: *mut c_void, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { scanner_set_statistics_callback_inner(scanner, callback, callback_ctx) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_statistics_callback_inner(scanner, callback, callback_ctx) ++ }) + } + + unsafe fn scanner_set_statistics_callback_inner( +@@ -734,6 +931,12 @@ unsafe fn scanner_set_statistics_callback_inner( + + /// Close and free a scanner handle. + /// ++/// Pending poll wakers are cancelled before the stream is dropped. If a poll ++/// waker callback is already running on another thread, close waits for that ++/// callback to return, making this function the retirement boundary for its ++/// callback context. A waker callback must therefore never close or otherwise ++/// re-enter its originating scanner. ++/// + /// Best-effort (issue #61): this drops a possibly-live + /// `DatasetRecordBatchStream`, the highest-risk `Drop` in this crate. A + /// panic raised while dropping the handle is caught and logged rather than +@@ -743,7 +946,9 @@ unsafe fn scanner_set_statistics_callback_inner( + pub unsafe extern "C" fn lance_scanner_close(scanner: *mut LanceScanner) { + if !scanner.is_null() { + swallow_unwind("lance_scanner_close", || unsafe { +- let _ = Box::from_raw(scanner); ++ let scanner = Box::from_raw(scanner); ++ scanner.poll_wakers.retire_and_wait(); ++ drop(scanner); + }); + } + } +@@ -770,27 +975,30 @@ pub unsafe extern "C" fn lance_scanner_to_arrow_stream( + scanner: *mut LanceScanner, + out: *mut FFI_ArrowArrayStream, + ) -> i32 { +- if scanner.is_null() || out.is_null() { +- set_last_error( +- LanceErrorCode::InvalidArgument, +- "scanner and out must not be NULL", +- ); ++ if scanner.is_null() { ++ set_last_error(LanceErrorCode::InvalidArgument, "scanner must not be NULL"); + return -1; + } + scanner_poison_check!(scanner, -1); ++ if out.is_null() { ++ set_last_error(LanceErrorCode::InvalidArgument, "out must not be NULL"); ++ return -1; ++ } + let s = unsafe { &*scanner }; + let poisoned = s.poison_flag(); +- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { +- scanner_to_arrow_stream_inner(s, out) +- })) { +- Ok(Ok(rc)) => { +- clear_last_error(); +- rc +- } +- Ok(Err(err)) => { +- set_lance_error(&err); +- -1 ++ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { ++ match unsafe { scanner_to_arrow_stream_inner(s, out) } { ++ Ok(rc) => { ++ clear_last_error(); ++ rc ++ } ++ Err(err) => { ++ set_lance_error(&err); ++ -1 ++ } + } ++ })) { ++ Ok(rc) => rc, + Err(payload) => { + poisoned.store(true, Ordering::SeqCst); + set_last_error( +@@ -848,14 +1056,18 @@ pub unsafe extern "C" fn lance_scanner_next( + scanner: *mut LanceScanner, + out: *mut *mut LanceBatch, + ) -> i32 { +- if scanner.is_null() || out.is_null() { +- set_last_error( +- LanceErrorCode::InvalidArgument, +- "scanner and out must not be NULL", +- ); ++ if !out.is_null() { ++ unsafe { *out = ptr::null_mut() }; ++ } ++ if scanner.is_null() { ++ set_last_error(LanceErrorCode::InvalidArgument, "scanner must not be NULL"); + return -1; + } + scanner_poison_check!(scanner, -1); ++ if out.is_null() { ++ set_last_error(LanceErrorCode::InvalidArgument, "out must not be NULL"); ++ return -1; ++ } + let s = unsafe { &mut *scanner }; + let poisoned = s.poison_flag(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { +@@ -919,16 +1131,22 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> + /// Start an async scan. The callback is invoked on a dedicated dispatcher thread + /// when the ArrowArrayStream is ready. + /// +-/// - `callback`: Called with `(ctx, 0, *mut ArrowArrayStream)` on success, +-/// or `(ctx, -1, NULL)` on error. On error, the dispatcher installs the +-/// error on the callback thread's TLS first, so `lance_last_error_*` +-/// called from inside the callback observes the failure. ++/// - `callback`: Must not be NULL. Called with ++/// `(ctx, 0, *mut ArrowArrayStream)` on success or `(ctx, -1, NULL)` on ++/// error. The successful result is a Rust-allocated outer stream container ++/// and must eventually be passed to [`lance_scanner_async_stream_free`]. On ++/// error, the dispatcher installs the error on the callback thread's TLS ++/// first, so `lance_last_error_*` called from inside the callback observes ++/// the failure. + /// - `callback_ctx`: Opaque pointer passed back to the callback. + /// + /// The scanner configuration is captured at call time. The scanner handle + /// can be closed immediately after this call. + /// +-/// The promised contract is exactly one callback completion, even on panic. ++/// With a non-NULL callback, the promised contract is exactly one completion, ++/// even on panic. Completions normally run on the dispatcher thread; if that ++/// thread cannot be created or its channel has failed, delivery falls back to ++/// the thread producing the completion rather than dropping it. + /// A panic anywhere in call-time setup (validation, scanner building, + /// runtime access, task spawn) is caught by the entry guard below and still + /// reported through the callback: `(ctx, -1, NULL)` with `LANCE_ERR_PANIC`, +@@ -939,9 +1157,13 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_scanner_scan_async( + scanner: *const LanceScanner, +- callback: LanceCallback, ++ callback: Option, + callback_ctx: *mut c_void, + ) { ++ let Some(callback) = callback else { ++ set_last_error(LanceErrorCode::InvalidArgument, "callback must not be NULL"); ++ return; ++ }; + unsafe { + scan_async_guarded(scanner, callback, callback_ctx, |s, cb, ctx| { + scan_async_setup(s, cb, ctx) +@@ -1148,6 +1370,22 @@ unsafe fn scan_async_setup( + }); + } + ++/// Release the heap-allocated Arrow stream container returned through a ++/// successful [`lance_scanner_scan_async`] callback. ++/// ++/// The Arrow stream's own `release` callback is invoked first when it is still ++/// present, then the outer Rust allocation is freed. Passing NULL is a no-op. ++/// This function must not be used for stack-allocated streams returned by ++/// [`lance_scanner_to_arrow_stream`]. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_scanner_async_stream_free(stream: *mut FFI_ArrowArrayStream) { ++ if !stream.is_null() { ++ swallow_unwind("lance_scanner_async_stream_free", || unsafe { ++ drop(Box::from_raw(stream)); ++ }); ++ } ++} ++ + // --------------------------------------------------------------------------- + // Poll-based iteration (for cooperative async runtimes) + // --------------------------------------------------------------------------- +@@ -1155,9 +1393,13 @@ unsafe fn scan_async_setup( + /// Poll for the next batch without blocking. + /// + /// - If data is already buffered, returns `LANCE_POLL_READY` immediately. +-/// - If I/O is needed, returns `LANCE_POLL_PENDING` and schedules the waker callback. ++/// - If I/O is needed, returns `LANCE_POLL_PENDING` and schedules the non-NULL ++/// waker callback. + /// The caller should yield the thread and re-poll after the waker fires. + /// - The waker is single-use: it fires at most once per poll call that returns PENDING. ++/// Its context must remain valid until the callback returns or ++/// `lance_scanner_close` returns. Close cancels callbacks that have not ++/// entered and waits for callbacks already in progress. + /// + /// The stream is lazily materialized on the first poll call (which will typically + /// return PENDING while the stream opens). +@@ -1168,18 +1410,26 @@ unsafe fn scan_async_setup( + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_scanner_poll_next( + scanner: *mut LanceScanner, +- waker: LanceWaker, ++ waker: Option, + waker_ctx: *mut c_void, + out: *mut *mut LanceBatch, + ) -> LancePollStatus { +- if scanner.is_null() || out.is_null() { +- set_last_error( +- LanceErrorCode::InvalidArgument, +- "scanner and out must not be NULL", +- ); ++ if !out.is_null() { ++ unsafe { *out = ptr::null_mut() }; ++ } ++ if scanner.is_null() { ++ set_last_error(LanceErrorCode::InvalidArgument, "scanner must not be NULL"); + return LancePollStatus::Error; + } + scanner_poison_check!(scanner, LancePollStatus::Error); ++ if out.is_null() { ++ set_last_error(LanceErrorCode::InvalidArgument, "out must not be NULL"); ++ return LancePollStatus::Error; ++ } ++ let Some(waker) = waker else { ++ set_last_error(LanceErrorCode::InvalidArgument, "waker must not be NULL"); ++ return LancePollStatus::Error; ++ }; + let s = unsafe { &mut *scanner }; + let poisoned = s.poison_flag(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { +@@ -1220,13 +1470,13 @@ unsafe fn scanner_poll_next_inner( + return LancePollStatus::Error; + } + +- let stream = s.stream.as_mut().unwrap(); +- + // Construct a std::task::Waker from the C function pointer. +- let raw_waker = make_raw_waker(waker, waker_ctx); ++ let raw_waker = make_raw_waker(&s.poll_wakers, waker, waker_ctx); + let waker_obj = unsafe { Waker::from_raw(raw_waker) }; + let mut cx = Context::from_waker(&waker_obj); + ++ let stream = s.stream.as_mut().unwrap(); ++ + // Enter the Tokio runtime context so internal I/O futures can access + // the reactor. Without this, polling from a non-Tokio thread panics. + let _guard = RT.enter(); +@@ -1264,39 +1514,66 @@ unsafe fn scanner_poll_next_inner( + struct CWakerContext { + waker_fn: LanceWaker, + ctx: *mut c_void, ++ state: Mutex, ++ quiesced: Condvar, ++} ++ ++#[derive(Default)] ++struct CWakerState { ++ fired: bool, ++ cancelled: bool, ++ active: bool, ++} ++ ++#[derive(Default)] ++struct PollWakerRegistry { ++ state: Mutex, ++} ++ ++#[derive(Default)] ++struct PollWakerRegistryState { ++ retired: bool, ++ registrations: Vec>, + } + + // C function pointers + void* are Send by convention for FFI. + unsafe impl Send for CWakerContext {} + unsafe impl Sync for CWakerContext {} + +-fn make_raw_waker(waker_fn: LanceWaker, ctx: *mut c_void) -> RawWaker { +- let data = Box::into_raw(Box::new(CWakerContext { waker_fn, ctx })) as *const (); ++fn make_raw_waker( ++ registry: &PollWakerRegistry, ++ waker_fn: LanceWaker, ++ ctx: *mut c_void, ++) -> RawWaker { ++ let context = Arc::new(CWakerContext { ++ waker_fn, ++ ctx, ++ state: Mutex::new(CWakerState::default()), ++ quiesced: Condvar::new(), ++ }); ++ registry.register(&context); ++ let data = Arc::into_raw(context) as *const (); + + const VTABLE: RawWakerVTable = RawWakerVTable::new( + // clone + |data| { +- let orig = unsafe { &*(data as *const CWakerContext) }; +- let cloned = Box::new(CWakerContext { +- waker_fn: orig.waker_fn, +- ctx: orig.ctx, +- }); +- RawWaker::new(Box::into_raw(cloned) as *const (), &VTABLE) ++ unsafe { Arc::::increment_strong_count(data.cast()) }; ++ RawWaker::new(data, &VTABLE) + }, + // wake (consumes) + |data| { +- let ctx = unsafe { Box::from_raw(data as *mut CWakerContext) }; +- unsafe { (ctx.waker_fn)(ctx.ctx) }; ++ let ctx = unsafe { Arc::from_raw(data as *const CWakerContext) }; ++ ctx.wake_once(); + }, + // wake_by_ref + |data| { + let ctx = unsafe { &*(data as *const CWakerContext) }; +- unsafe { (ctx.waker_fn)(ctx.ctx) }; ++ ctx.wake_once(); + }, + // drop + |data| { + unsafe { +- let _ = Box::from_raw(data as *mut CWakerContext); ++ drop(Arc::from_raw(data as *const CWakerContext)); + }; + }, + ); +@@ -1304,6 +1581,99 @@ fn make_raw_waker(waker_fn: LanceWaker, ctx: *mut c_void) -> RawWaker { + RawWaker::new(data, &VTABLE) + } + ++impl CWakerContext { ++ fn wake_once(&self) { ++ { ++ let mut state = self ++ .state ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ if state.cancelled || state.fired { ++ return; ++ } ++ state.fired = true; ++ state.active = true; ++ } ++ ++ unsafe { (self.waker_fn)(self.ctx) }; ++ ++ let mut state = self ++ .state ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ state.active = false; ++ self.quiesced.notify_all(); ++ } ++ ++ fn cancel(&self) { ++ self.state ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()) ++ .cancelled = true; ++ } ++ ++ fn wait_until_quiescent(&self) { ++ let mut state = self ++ .state ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ while state.active { ++ state = self ++ .quiesced ++ .wait(state) ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ } ++ } ++} ++ ++impl PollWakerRegistry { ++ fn register(&self, registration: &Arc) { ++ let retired = { ++ let mut state = self ++ .state ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ state ++ .registrations ++ .retain(|candidate| candidate.strong_count() > 0); ++ if state.retired { ++ true ++ } else { ++ state.registrations.push(Arc::downgrade(registration)); ++ false ++ } ++ }; ++ if retired { ++ registration.cancel(); ++ } ++ } ++ ++ fn retire_and_wait(&self) { ++ let registrations = { ++ let mut state = self ++ .state ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ state.retired = true; ++ state ++ .registrations ++ .drain(..) ++ .filter_map(|registration| registration.upgrade()) ++ .collect::>() ++ }; ++ ++ // Cancel every registration before waiting for any one callback, so ++ // no later registration can enter while close is quiescing an earlier ++ // one. ++ for registration in ®istrations { ++ registration.cancel(); ++ } ++ for registration in registrations { ++ registration.wait_until_quiescent(); ++ } ++ } ++} ++ + // --------------------------------------------------------------------------- + // Vector search (Phase 2): setter knobs + // --------------------------------------------------------------------------- +@@ -1313,7 +1683,8 @@ macro_rules! scanner_set_u32 { + #[unsafe(no_mangle)] + pub unsafe extern "C" fn $name(scanner: *mut LanceScanner, value: u32) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( ++ scanner_ffi_try!( ++ scanner, + (|| -> Result { + if scanner.is_null() { + return Err(lance_core::Error::invalid_input_source( +@@ -1324,8 +1695,7 @@ macro_rules! scanner_set_u32 { + (*scanner).$field = Some(value); + } + Ok(0) +- })(), +- neg ++ })() + ) + } + }; +@@ -1338,7 +1708,9 @@ scanner_set_u32!(lance_scanner_set_ef, ef); + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_scanner_set_metric(scanner: *mut LanceScanner, metric: i32) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!(unsafe { scanner_set_metric_inner(scanner, metric) }, neg) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_metric_inner(scanner, metric) ++ }) + } + + unsafe fn scanner_set_metric_inner(scanner: *mut LanceScanner, metric: i32) -> Result { +@@ -1370,7 +1742,9 @@ pub unsafe extern "C" fn lance_scanner_set_use_index( + enable: bool, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!(unsafe { scanner_set_use_index_inner(scanner, enable) }, neg) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_use_index_inner(scanner, enable) ++ }) + } + + unsafe fn scanner_set_use_index_inner(scanner: *mut LanceScanner, enable: bool) -> Result { +@@ -1391,7 +1765,9 @@ pub unsafe extern "C" fn lance_scanner_set_prefilter( + enable: bool, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!(unsafe { scanner_set_prefilter_inner(scanner, enable) }, neg) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_prefilter_inner(scanner, enable) ++ }) + } + + unsafe fn scanner_set_prefilter_inner(scanner: *mut LanceScanner, enable: bool) -> Result { +@@ -1424,10 +1800,9 @@ pub unsafe extern "C" fn lance_scanner_set_index_segments( + len: usize, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { scanner_set_index_segments_inner(scanner, segment_uuids, len) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_index_segments_inner(scanner, segment_uuids, len) ++ }) + } + + unsafe fn scanner_set_index_segments_inner( +@@ -1485,10 +1860,9 @@ pub unsafe extern "C" fn lance_scanner_nearest( + k: u32, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { scanner_nearest_inner(scanner, column, query_data, query_len, element_type, k) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_nearest_inner(scanner, column, query_data, query_len, element_type, k) ++ },) + } + + unsafe fn scanner_nearest_inner( +@@ -1510,9 +1884,9 @@ unsafe fn scanner_nearest_inner( + )); + } + let s = unsafe { &mut *scanner }; +- if s.fts_query.is_some() { ++ if s.fts_query.is_some() || s.fts_context.is_some() { + return Err(lance_core::Error::invalid_input_source( +- "cannot call nearest after full_text_search; they are mutually exclusive".into(), ++ "cannot call nearest after full_text_search or attaching an FTS query context; they are mutually exclusive".into(), + )); + } + let column_str = unsafe { helpers::parse_c_string(column)? }.unwrap(); +@@ -1586,10 +1960,9 @@ pub unsafe extern "C" fn lance_scanner_full_text_search( + max_fuzzy_distance: u32, + ) -> i32 { + scanner_poison_check!(scanner, -1); +- ffi_try!( +- unsafe { fts_inner(scanner, query, columns, max_fuzzy_distance) }, +- neg +- ) ++ scanner_ffi_try!(scanner, unsafe { ++ fts_inner(scanner, query, columns, max_fuzzy_distance) ++ },) + } + + unsafe fn fts_inner( +@@ -1611,6 +1984,11 @@ unsafe fn fts_inner( + "cannot call full_text_search after nearest; they are mutually exclusive".into(), + )); + } ++ if s.fts_context.is_some() { ++ return Err(lance_core::Error::invalid_input_source( ++ "cannot call full_text_search after attaching an FTS query context; the context already owns the query".into(), ++ )); ++ } + + let query_str = unsafe { helpers::parse_c_string(query)? } + .unwrap() +@@ -1633,13 +2011,89 @@ unsafe fn fts_inner( + Ok(0) + } + ++/// Attach an immutable, process-local FTS query context to this scanner. ++/// The scanner clones the context's shared ownership; the caller may close ++/// the public context handle after this function returns successfully. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_scanner_set_fts_query_context( ++ scanner: *mut LanceScanner, ++ context: *const LanceFtsQueryContext, ++) -> i32 { ++ scanner_poison_check!(scanner, -1); ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_fts_query_context_inner(scanner, context) ++ }) ++} ++ ++unsafe fn scanner_set_fts_query_context_inner( ++ scanner: *mut LanceScanner, ++ context: *const LanceFtsQueryContext, ++) -> Result { ++ if scanner.is_null() { ++ return Err(lance_core::Error::invalid_input_source( ++ "scanner must not be NULL".into(), ++ )); ++ } ++ let context = unsafe { clone_context(context)? }; ++ let scanner = unsafe { &mut *scanner }; ++ if scanner.nearest.is_some() { ++ return Err(lance_core::Error::invalid_input_source( ++ "cannot attach an FTS query context after nearest; they are mutually exclusive".into(), ++ )); ++ } ++ if scanner.fts_query.is_some() { ++ return Err(lance_core::Error::invalid_input_source( ++ "cannot attach an FTS query context after full_text_search; the context already owns the query" ++ .into(), ++ )); ++ } ++ context.validate_dataset_identity(&scanner.dataset)?; ++ scanner.fts_context = Some(context); ++ Ok(0) ++} ++ ++/// Restrict a context-backed FTS scan to a subset of segment UUIDs. ++/// Passing `len == 0` clears the restriction so all context segments are used. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_scanner_set_fts_index_segments( ++ scanner: *mut LanceScanner, ++ segment_uuids: *const u8, ++ len: usize, ++) -> i32 { ++ scanner_poison_check!(scanner, -1); ++ scanner_ffi_try!(scanner, unsafe { ++ scanner_set_fts_index_segments_inner(scanner, segment_uuids, len) ++ }) ++} ++ ++unsafe fn scanner_set_fts_index_segments_inner( ++ scanner: *mut LanceScanner, ++ segment_uuids: *const u8, ++ len: usize, ++) -> Result { ++ if scanner.is_null() { ++ return Err(lance_core::Error::invalid_input_source( ++ "scanner must not be NULL".into(), ++ )); ++ } ++ let segments = if len == 0 { ++ None ++ } else { ++ Some(parse_segment_uuids(segment_uuids, len)?) ++ }; ++ unsafe { &mut *scanner }.fts_index_segments = segments; ++ Ok(0) ++} ++ + #[cfg(test)] + mod tests { + use super::*; + use crate::dataset::{lance_dataset_close, lance_dataset_open}; + use crate::error::{lance_last_error_code, lance_last_error_message}; + use std::ffi::{CStr, CString}; +- use std::sync::atomic::AtomicI32; ++ use std::sync::atomic::{AtomicI32, AtomicUsize}; ++ use std::sync::{Barrier, mpsc}; ++ use std::time::Duration; + + use arrow_array::{Int32Array, RecordBatch, StringArray}; + use arrow_schema::{DataType, Field, Schema}; +@@ -1706,6 +2160,198 @@ mod tests { + + unsafe extern "C" fn noop_waker(_ctx: *mut c_void) {} + ++ #[test] ++ fn null_async_callback_is_rejected_without_poisoning_scanner() { ++ let (_tmp, uri) = create_test_dataset(); ++ let (dataset, scanner) = open_dataset_and_scanner(&uri); ++ ++ unsafe { lance_scanner_scan_async(scanner, None, ptr::null_mut()) }; ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ let msg_ptr = lance_last_error_message(); ++ assert!(!msg_ptr.is_null()); ++ let msg = unsafe { CStr::from_ptr(msg_ptr) }.to_string_lossy(); ++ assert!(msg.contains("callback must not be NULL"), "got: {msg}"); ++ unsafe { crate::error::lance_free_string(msg_ptr) }; ++ assert!(!unsafe { &*scanner }.is_poisoned()); ++ ++ unsafe { ++ lance_scanner_close(scanner); ++ lance_dataset_close(dataset); ++ } ++ } ++ ++ #[test] ++ fn null_poll_waker_is_rejected_and_clears_out() { ++ let (_tmp, uri) = create_test_dataset(); ++ let (dataset, scanner) = open_dataset_and_scanner(&uri); ++ let mut batch = std::ptr::NonNull::::dangling().as_ptr(); ++ ++ let status = unsafe { lance_scanner_poll_next(scanner, None, ptr::null_mut(), &mut batch) }; ++ assert_eq!(status, LancePollStatus::Error); ++ assert!(batch.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ assert!(!unsafe { &*scanner }.is_poisoned()); ++ ++ unsafe { ++ lance_scanner_close(scanner); ++ lance_dataset_close(dataset); ++ } ++ } ++ ++ #[test] ++ fn raw_waker_clones_share_one_shot_gate() { ++ static WAKES: AtomicUsize = AtomicUsize::new(0); ++ unsafe extern "C" fn count_wake(_ctx: *mut c_void) { ++ WAKES.fetch_add(1, Ordering::SeqCst); ++ } ++ ++ WAKES.store(0, Ordering::SeqCst); ++ let registry = PollWakerRegistry::default(); ++ let waker = ++ unsafe { Waker::from_raw(make_raw_waker(®istry, count_wake, ptr::null_mut())) }; ++ let cloned = waker.clone(); ++ waker.wake_by_ref(); ++ cloned.wake_by_ref(); ++ drop(cloned); ++ drop(waker); ++ assert_eq!(WAKES.load(Ordering::SeqCst), 1); ++ } ++ ++ #[test] ++ fn scanner_close_cancels_a_retained_poll_waker() { ++ let (_tmp, uri) = create_test_dataset(); ++ let (dataset, scanner) = open_dataset_and_scanner(&uri); ++ let calls = Box::into_raw(Box::new(AtomicUsize::new(0))); ++ ++ unsafe extern "C" fn count_wake(ctx: *mut c_void) { ++ let calls = unsafe { &*(ctx.cast::()) }; ++ calls.fetch_add(1, Ordering::SeqCst); ++ } ++ ++ // Model a future retaining the RawWaker clone returned from a PENDING ++ // poll. Closing the scanner is the documented retirement boundary, so ++ // waking that retained clone afterwards must not touch callback_ctx. ++ let waker = unsafe { ++ Waker::from_raw(make_raw_waker( ++ &(*scanner).poll_wakers, ++ count_wake, ++ calls.cast(), ++ )) ++ }; ++ unsafe { lance_scanner_close(scanner) }; ++ waker.wake(); ++ ++ let calls = unsafe { Box::from_raw(calls) }; ++ assert_eq!( ++ calls.load(Ordering::SeqCst), ++ 0, ++ "a retained RawWaker invoked callback_ctx after scanner close" ++ ); ++ unsafe { lance_dataset_close(dataset) }; ++ } ++ ++ struct BlockingWakeProbe { ++ calls: AtomicUsize, ++ entered: Arc, ++ release: Arc, ++ } ++ ++ unsafe extern "C" fn blocking_waker(ctx: *mut c_void) { ++ let probe = unsafe { &*(ctx.cast::()) }; ++ probe.calls.fetch_add(1, Ordering::SeqCst); ++ probe.entered.wait(); ++ probe.release.wait(); ++ } ++ ++ #[test] ++ fn scanner_close_waits_for_an_active_poll_waker() { ++ let (_tmp, uri) = create_test_dataset(); ++ let (dataset, scanner) = open_dataset_and_scanner(&uri); ++ let entered = Arc::new(Barrier::new(2)); ++ let release = Arc::new(Barrier::new(2)); ++ let probe = Box::into_raw(Box::new(BlockingWakeProbe { ++ calls: AtomicUsize::new(0), ++ entered: Arc::clone(&entered), ++ release: Arc::clone(&release), ++ })); ++ let waker = unsafe { ++ Waker::from_raw(make_raw_waker( ++ &(*scanner).poll_wakers, ++ blocking_waker, ++ probe.cast(), ++ )) ++ }; ++ ++ let wake_thread = std::thread::spawn(move || waker.wake()); ++ entered.wait(); ++ ++ let close_started = Arc::new(Barrier::new(2)); ++ let close_started_in_thread = Arc::clone(&close_started); ++ let (closed_tx, closed_rx) = mpsc::channel(); ++ let scanner_address = scanner as usize; ++ let close_thread = std::thread::spawn(move || { ++ close_started_in_thread.wait(); ++ unsafe { lance_scanner_close(scanner_address as *mut LanceScanner) }; ++ closed_tx.send(()).unwrap(); ++ }); ++ close_started.wait(); ++ ++ let closed_while_callback_was_active = ++ closed_rx.recv_timeout(Duration::from_millis(500)).is_ok(); ++ release.wait(); ++ wake_thread.join().unwrap(); ++ close_thread.join().unwrap(); ++ ++ let probe = unsafe { Box::from_raw(probe) }; ++ assert_eq!(probe.calls.load(Ordering::SeqCst), 1); ++ assert!( ++ !closed_while_callback_was_active, ++ "scanner close returned before an active poll waker callback completed" ++ ); ++ unsafe { lance_dataset_close(dataset) }; ++ } ++ ++ fn panicking_setter_body() -> Result { ++ panic!("simulated panic in scanner setter") ++ } ++ ++ unsafe fn panicking_scanner_setter(scanner: *mut LanceScanner) -> i32 { ++ scanner_poison_check!(scanner, -1); ++ scanner_ffi_try!(scanner, panicking_setter_body()) ++ } ++ ++ #[test] ++ fn scanner_setter_panic_poisons_handle() { ++ let (_tmp, uri) = create_test_dataset(); ++ let (dataset, scanner) = open_dataset_and_scanner(&uri); ++ ++ let rc = unsafe { panicking_scanner_setter(scanner) }; ++ assert_eq!(rc, -1); ++ assert!(unsafe { &*scanner }.is_poisoned()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::Panic); ++ let msg_ptr = lance_last_error_message(); ++ assert!(!msg_ptr.is_null()); ++ let msg = unsafe { CStr::from_ptr(msg_ptr) } ++ .to_string_lossy() ++ .into_owned(); ++ unsafe { crate::error::lance_free_string(msg_ptr) }; ++ assert!( ++ msg.contains("simulated panic in scanner setter"), ++ "got: {msg}" ++ ); ++ ++ // The original panic message is reported once; later calls use the ++ // stable poison error and never touch scanner state again. ++ let rc = unsafe { lance_scanner_set_limit(scanner, 10) }; ++ assert_eq!(rc, -1); ++ assert_poison_error_pending(); ++ ++ unsafe { ++ lance_scanner_close(scanner); ++ lance_dataset_close(dataset); ++ } ++ } ++ + #[test] + fn poisoned_scanner_rejects_setters_with_panic_code() { + let (_tmp, uri) = create_test_dataset(); +@@ -1745,12 +2391,17 @@ mod tests { + let (dataset, scanner) = open_dataset_and_scanner(&uri); + poison(scanner); + +- let mut batch: *mut LanceBatch = ptr::null_mut(); ++ let mut batch = std::ptr::NonNull::::dangling().as_ptr(); + let rc = unsafe { lance_scanner_next(scanner, &mut batch) }; + assert_eq!(rc, -1); + assert!(batch.is_null(), "error path must leave *out NULL"); + assert_poison_error_pending(); + ++ // Poison has precedence over validation of secondary arguments. ++ let rc = unsafe { lance_scanner_next(scanner, ptr::null_mut()) }; ++ assert_eq!(rc, -1); ++ assert_poison_error_pending(); ++ + unsafe { + lance_scanner_close(scanner); + lance_dataset_close(dataset); +@@ -1763,13 +2414,20 @@ mod tests { + let (dataset, scanner) = open_dataset_and_scanner(&uri); + poison(scanner); + +- let mut batch: *mut LanceBatch = ptr::null_mut(); +- let status = +- unsafe { lance_scanner_poll_next(scanner, noop_waker, ptr::null_mut(), &mut batch) }; ++ let mut batch = std::ptr::NonNull::::dangling().as_ptr(); ++ let status = unsafe { ++ lance_scanner_poll_next(scanner, Some(noop_waker), ptr::null_mut(), &mut batch) ++ }; + assert_eq!(status, LancePollStatus::Error); + assert!(batch.is_null(), "error path must leave *out NULL"); + assert_poison_error_pending(); + ++ let status = unsafe { ++ lance_scanner_poll_next(scanner, Some(noop_waker), ptr::null_mut(), ptr::null_mut()) ++ }; ++ assert_eq!(status, LancePollStatus::Error); ++ assert_poison_error_pending(); ++ + unsafe { + lance_scanner_close(scanner); + lance_dataset_close(dataset); +@@ -1809,7 +2467,7 @@ mod tests { + let (dataset, scanner) = open_dataset_and_scanner(&uri); + poison(scanner); + +- unsafe { lance_scanner_scan_async(scanner, record_status, ptr::null_mut()) }; ++ unsafe { lance_scanner_scan_async(scanner, Some(record_status), ptr::null_mut()) }; + // The poison error is also visible on the calling thread. + assert_poison_error_pending(); + +diff --git a/src/stream_guard.rs b/src/stream_guard.rs +index f4f418e..d785082 100644 +--- a/src/stream_guard.rs ++++ b/src/stream_guard.rs +@@ -43,6 +43,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + ++use arrow::ffi::FFI_ArrowSchema; ++use arrow::ffi_stream::FFI_ArrowArrayStream; + use arrow::record_batch::RecordBatchReader; + use arrow_array::RecordBatch; + use arrow_schema::{ArrowError, SchemaRef}; +@@ -50,6 +52,44 @@ use futures::{Stream, StreamExt}; + + use crate::error::{panic_payload_message, swallow_unwind}; + ++/// An owned, NUL-free error whose `Display` implementation cannot call back ++/// into an arbitrary external error source. Arrow formats this value from ++/// inside its non-unwinding `get_next` callback. ++#[derive(Debug)] ++struct FfiSafeStreamError(String); ++ ++impl std::fmt::Display for FfiSafeStreamError { ++ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ f.write_str(&self.0) ++ } ++} ++ ++impl std::error::Error for FfiSafeStreamError {} ++ ++fn ffi_safe_stream_error(message: String) -> ArrowError { ++ ArrowError::ExternalError(Box::new(FfiSafeStreamError(message.replace('\0', "\\0")))) ++} ++ ++/// Exercise arrow-rs's exact schema conversion before its non-unwinding ++/// `get_schema` callback is exposed to C. ++fn preflight_schema(schema: &SchemaRef) -> std::result::Result<(), ArrowError> { ++ match catch_unwind(AssertUnwindSafe(|| { ++ let ffi_schema = FFI_ArrowSchema::try_from(schema.as_ref()).map_err(|err| { ++ // Detach the error under the guard for the same reason `next` ++ // does: this value may ultimately be formatted by an FFI caller. ++ ffi_safe_stream_error(err.to_string()) ++ })?; ++ drop(ffi_schema); ++ Ok(()) ++ })) { ++ Ok(result) => result, ++ Err(payload) => Err(ffi_safe_stream_error(format!( ++ "panic exporting Arrow schema: {}", ++ panic_payload_message(&*payload) ++ ))), ++ } ++} ++ + /// A [`RecordBatchReader`] that owns the exported Lance stream, drives it + /// with a Tokio runtime handle, and contains panics at both C-reachable + /// edges (`next` and `drop`) — see the module docs for why the guard lives +@@ -74,12 +114,26 @@ impl GuardedReader { + /// Wrap `inner`, driving it with `handle` and wiring the shared + /// `scanner_poison` flag that a caught panic sets (from + /// `LanceScanner::poison_flag()` at the export sites). ++ /// ++ /// # Panics ++ /// ++ /// Panics if `schema` cannot be converted to the Arrow C Data Interface. ++ /// Production callers construct this reader inside their outer FFI panic ++ /// guard, before arrow-rs's non-unwinding `get_schema` callback is exposed. + pub fn new( + inner: S, + schema: SchemaRef, + handle: tokio::runtime::Handle, + scanner_poison: Arc, + ) -> Self { ++ // arrow-rs converts this schema later from inside its non-unwinding ++ // `get_schema` callback. Perform the exact conversion once while the ++ // scanner export's outer catch_unwind is still active, so a malformed ++ // schema (for example, a field name containing NUL) cannot first ++ // panic after control has crossed into that callback. ++ preflight_schema(&schema) ++ .unwrap_or_else(|err| panic!("Arrow schema cannot be exported: {err}")); ++ + Self { + inner: Some(inner), + schema, +@@ -115,19 +169,27 @@ where + // stream's `poll_next` lands here, one frame below arrow-rs's + // `extern "C"` callback, so neither can unwind across the FFI + // boundary. +- let polled = catch_unwind(AssertUnwindSafe(|| handle.block_on(inner.next()))); ++ let polled = catch_unwind(AssertUnwindSafe(|| { ++ match handle.block_on(inner.next()) { ++ Some(Ok(batch)) => Some(Ok(batch)), ++ Some(Err(err)) => { ++ // Format and detach the arbitrary Lance error while still ++ // inside the guard. arrow-rs later calls Display and ++ // CString::new from a non-unwinding callback, so neither a ++ // panicking source nor an embedded NUL may reach it. ++ Some(Err(ffi_safe_stream_error(err.to_string()))) ++ } ++ None => None, ++ } ++ })); + match polled { +- Ok(Some(Ok(batch))) => Some(Ok(batch)), +- Ok(Some(Err(err))) => Some(Err(ArrowError::ExternalError(Box::new(err)))), +- Ok(None) => None, ++ Ok(item) => item, + Err(payload) => { + *poisoned = true; + scanner_poison.store(true, Ordering::SeqCst); +- Some(Err(ArrowError::ExternalError(Box::new( +- lance_core::Error::internal(format!( +- "panic in stream: {}", +- panic_payload_message(&*payload) +- )), ++ Some(Err(ffi_safe_stream_error(format!( ++ "panic in stream: {}", ++ panic_payload_message(&*payload) + )))) + } + } +@@ -158,11 +220,101 @@ impl Drop for GuardedReader { + } + } + ++/// A panic-safe owner for an already-materialized [`RecordBatchReader`]. ++/// ++/// Dataset `take` operations use readers whose batches are already in memory, ++/// so no Tokio handle is needed. Arrow still invokes `schema`, `next`, and ++/// `drop` later from non-unwinding C callbacks, however, which requires the ++/// same error-detachment and cleanup containment as [`GuardedReader`]. ++struct GuardedRecordBatchReader { ++ inner: Option, ++ schema: SchemaRef, ++ poisoned: bool, ++} ++ ++impl Iterator for GuardedRecordBatchReader ++where ++ R: RecordBatchReader, ++{ ++ type Item = std::result::Result; ++ ++ fn next(&mut self) -> Option { ++ if self.poisoned { ++ return None; ++ } ++ ++ let inner = self.inner.as_mut()?; ++ let next = catch_unwind(AssertUnwindSafe(|| match inner.next() { ++ Some(Ok(batch)) => Some(Ok(batch)), ++ Some(Err(err)) => Some(Err(ffi_safe_stream_error(err.to_string()))), ++ None => None, ++ })); ++ ++ match next { ++ Ok(item) => item, ++ Err(payload) => { ++ self.poisoned = true; ++ Some(Err(ffi_safe_stream_error(format!( ++ "panic in record batch reader: {}", ++ panic_payload_message(&*payload) ++ )))) ++ } ++ } ++ } ++} ++ ++impl RecordBatchReader for GuardedRecordBatchReader ++where ++ R: RecordBatchReader + Send, ++{ ++ fn schema(&self) -> SchemaRef { ++ Arc::clone(&self.schema) ++ } ++} ++ ++impl Drop for GuardedRecordBatchReader { ++ fn drop(&mut self) { ++ let Some(inner) = self.inner.take() else { ++ return; ++ }; ++ swallow_unwind( ++ "GuardedRecordBatchReader::drop (ArrowArrayStream release)", ++ || drop(inner), ++ ); ++ } ++} ++ ++/// Export an already-materialized reader through panic-safe Arrow C stream ++/// callbacks. ++/// ++/// The schema is converted once before the callback table is returned. This ++/// turns deterministic schema conversion failures into an ordinary export ++/// failure (or lets the caller's outer FFI guard catch an arrow-rs conversion ++/// panic) instead of deferring them to `get_schema`. ++pub(crate) fn guarded_ffi_stream_from_reader( ++ reader: R, ++) -> std::result::Result ++where ++ R: RecordBatchReader + Send + 'static, ++{ ++ let schema = reader.schema(); ++ preflight_schema(&schema)?; ++ let reader = GuardedRecordBatchReader { ++ inner: Some(reader), ++ schema, ++ poisoned: false, ++ }; ++ Ok(FFI_ArrowArrayStream::new(Box::new(reader))) ++} ++ + #[cfg(test)] + mod tests { + use super::*; ++ use arrow::ffi::FFI_ArrowArray; ++ use arrow::ffi_stream::FFI_ArrowArrayStream; + use arrow_array::Int32Array; + use arrow_schema::{DataType, Field, Schema}; ++ use std::ffi::CStr; + use std::pin::Pin; + use std::task::{Context, Poll}; + +@@ -187,6 +339,17 @@ mod tests { + message: &'static str, + } + ++ #[derive(Debug)] ++ struct PanickingDisplay; ++ ++ impl std::fmt::Display for PanickingDisplay { ++ fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ panic!("simulated panic while formatting a reader error") ++ } ++ } ++ ++ impl std::error::Error for PanickingDisplay {} ++ + impl Stream for PanicOnSecondPoll { + type Item = lance_core::Result; + +@@ -212,6 +375,76 @@ mod tests { + (rt, reader) + } + ++ fn guarded_export( ++ stream: S, ++ schema: SchemaRef, ++ scanner_poison: Arc, ++ ) -> (tokio::runtime::Runtime, FFI_ArrowArrayStream) ++ where ++ S: Stream> + Unpin + Send + 'static, ++ { ++ let rt = tokio::runtime::Runtime::new().unwrap(); ++ let reader = GuardedReader::new(stream, schema, rt.handle().clone(), scanner_poison); ++ (rt, FFI_ArrowArrayStream::new(Box::new(reader))) ++ } ++ ++ unsafe fn c_get_next(stream: *mut FFI_ArrowArrayStream, array: *mut FFI_ArrowArray) -> i32 { ++ let get_next = unsafe { (*stream).get_next }.expect("get_next callback is NULL"); ++ unsafe { get_next(stream, array) } ++ } ++ ++ unsafe fn c_get_schema(stream: *mut FFI_ArrowArrayStream, schema: *mut FFI_ArrowSchema) -> i32 { ++ let get_schema = unsafe { (*stream).get_schema }.expect("get_schema callback is NULL"); ++ unsafe { get_schema(stream, schema) } ++ } ++ ++ unsafe fn c_get_last_error(stream: *mut FFI_ArrowArrayStream) -> Option { ++ let get_last_error = ++ unsafe { (*stream).get_last_error }.expect("get_last_error callback is NULL"); ++ let message = unsafe { get_last_error(stream) }; ++ if message.is_null() { ++ None ++ } else { ++ Some( ++ unsafe { CStr::from_ptr(message) } ++ .to_string_lossy() ++ .into_owned(), ++ ) ++ } ++ } ++ ++ fn run_child(test_name: &str, environment_variable: &str) -> std::process::Output { ++ let exact_name = format!("stream_guard::tests::{test_name}"); ++ std::process::Command::new(std::env::current_exe().unwrap()) ++ .args([&exact_name, "--exact", "--nocapture", "--test-threads=1"]) ++ .env(environment_variable, "1") ++ .output() ++ .unwrap() ++ } ++ ++ fn assert_child_succeeds(test_name: &str, environment_variable: &str) -> String { ++ let output = run_child(test_name, environment_variable); ++ let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); ++ assert!( ++ output.status.success(), ++ "guarded child must exit cleanly, got status {:?}\nstderr:\n{stderr}", ++ output.status ++ ); ++ stderr ++ } ++ ++ fn raw_error_then_eos(stream: &mut FFI_ArrowArrayStream) -> String { ++ let mut array = FFI_ArrowArray::empty(); ++ let status = unsafe { c_get_next(stream, &mut array) }; ++ assert_ne!(status, 0, "expected an Arrow C stream error"); ++ let message = unsafe { c_get_last_error(stream) }.expect("get_last_error returned NULL"); ++ ++ let mut eos = FFI_ArrowArray::empty(); ++ assert_eq!(unsafe { c_get_next(stream, &mut eos) }, 0); ++ assert!(eos.release.is_none(), "error must be followed by EOS"); ++ message ++ } ++ + #[test] + fn panic_yields_one_error_then_fuses_and_flips_flag() { + let scanner_poison = Arc::new(AtomicBool::new(false)); +@@ -363,6 +596,48 @@ mod tests { + } + } + ++ struct PanicOnReaderNext { ++ schema: SchemaRef, ++ } ++ ++ impl Iterator for PanicOnReaderNext { ++ type Item = std::result::Result; ++ ++ fn next(&mut self) -> Option { ++ panic!("simulated panic in materialized reader next"); ++ } ++ } ++ ++ impl RecordBatchReader for PanicOnReaderNext { ++ fn schema(&self) -> SchemaRef { ++ Arc::clone(&self.schema) ++ } ++ } ++ ++ struct PanicOnReaderDrop { ++ schema: SchemaRef, ++ } ++ ++ impl Iterator for PanicOnReaderDrop { ++ type Item = std::result::Result; ++ ++ fn next(&mut self) -> Option { ++ None ++ } ++ } ++ ++ impl RecordBatchReader for PanicOnReaderDrop { ++ fn schema(&self) -> SchemaRef { ++ Arc::clone(&self.schema) ++ } ++ } ++ ++ impl Drop for PanicOnReaderDrop { ++ fn drop(&mut self) { ++ panic!("simulated panic in materialized reader drop"); ++ } ++ } ++ + /// Regression for the review finding that the release path was unguarded: + /// arrow-rs's `release_stream` drops this reader inside its `extern "C"` + /// callback, so a cleanup panic must be contained here (best-effort: +@@ -381,4 +656,311 @@ mod tests { + "cleanup panic is best-effort and must not poison the handle" + ); + } ++ ++ #[test] ++ fn raw_stream_get_next_contains_poll_panic() { ++ const CHILD: &str = "LANCE_C_CHILD_STREAM_POLL_PANIC"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = assert_child_succeeds("raw_stream_get_next_contains_poll_panic", CHILD); ++ assert!(stderr.contains("simulated raw poll panic")); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let (_runtime, mut stream) = guarded_export( ++ PanicOnSecondPoll { ++ yielded: false, ++ message: "simulated raw poll panic", ++ }, ++ test_schema(), ++ Arc::clone(&scanner_poison), ++ ); ++ ++ let mut first = FFI_ArrowArray::empty(); ++ assert_eq!(unsafe { c_get_next(&mut stream, &mut first) }, 0); ++ assert!(first.release.is_some()); ++ unsafe { first.release.unwrap()(&mut first) }; ++ ++ let message = raw_error_then_eos(&mut stream); ++ assert!( ++ message.contains("simulated raw poll panic"), ++ "got: {message}" ++ ); ++ assert!(scanner_poison.load(Ordering::SeqCst)); ++ } ++ ++ #[test] ++ fn raw_stream_get_next_sanitizes_regular_error() { ++ const CHILD: &str = "LANCE_C_CHILD_STREAM_NUL_ERROR"; ++ if std::env::var(CHILD).is_err() { ++ assert_child_succeeds("raw_stream_get_next_sanitizes_regular_error", CHILD); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( ++ "ordinary error with a NUL: bo\0om".into(), ++ ))]); ++ let (_runtime, mut stream) = ++ guarded_export(stream, test_schema(), Arc::clone(&scanner_poison)); ++ ++ let message = raw_error_then_eos(&mut stream); ++ assert!(message.contains("bo\\0om"), "got: {message:?}"); ++ assert!(!message.contains('\0')); ++ assert!(!scanner_poison.load(Ordering::SeqCst)); ++ } ++ ++ #[test] ++ fn raw_stream_get_next_contains_error_display_panic() { ++ const CHILD: &str = "LANCE_C_CHILD_STREAM_DISPLAY_PANIC"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = ++ assert_child_succeeds("raw_stream_get_next_contains_error_display_panic", CHILD); ++ assert!(stderr.contains("simulated panic while formatting a reader error")); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( ++ Box::new(PanickingDisplay), ++ ))]); ++ let (_runtime, mut stream) = ++ guarded_export(stream, test_schema(), Arc::clone(&scanner_poison)); ++ ++ let message = raw_error_then_eos(&mut stream); ++ assert!( ++ message.contains("simulated panic while formatting a reader error"), ++ "got: {message}" ++ ); ++ assert!(scanner_poison.load(Ordering::SeqCst)); ++ } ++ ++ #[test] ++ fn stream_schema_is_rejected_before_raw_get_schema_is_exposed() { ++ const CHILD: &str = "LANCE_C_CHILD_STREAM_NUL_SCHEMA"; ++ if std::env::var(CHILD).is_err() { ++ assert_child_succeeds( ++ "stream_schema_is_rejected_before_raw_get_schema_is_exposed", ++ CHILD, ++ ); ++ return; ++ } ++ ++ let schema = Arc::new(Schema::new(vec![Field::new( ++ "field\0name", ++ DataType::Int32, ++ false, ++ )])); ++ let runtime = tokio::runtime::Runtime::new().unwrap(); ++ let callback_was_exposed = std::cell::Cell::new(false); ++ let outcome = catch_unwind(AssertUnwindSafe(|| { ++ let reader = GuardedReader::new( ++ futures::stream::empty::>(), ++ schema, ++ runtime.handle().clone(), ++ Arc::new(AtomicBool::new(false)), ++ ); ++ callback_was_exposed.set(true); ++ let mut stream = FFI_ArrowArrayStream::new(Box::new(reader)); ++ let mut ffi_schema = FFI_ArrowSchema::empty(); ++ unsafe { c_get_schema(&mut stream, &mut ffi_schema) } ++ })); ++ ++ assert!(outcome.is_err(), "invalid schema must fail during export"); ++ assert!( ++ !callback_was_exposed.get(), ++ "invalid schema reached raw get_schema" ++ ); ++ } ++ ++ #[test] ++ fn raw_stream_get_next_inside_tokio_runtime_is_contained() { ++ const CHILD: &str = "LANCE_C_CHILD_STREAM_NESTED_RUNTIME"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = assert_child_succeeds( ++ "raw_stream_get_next_inside_tokio_runtime_is_contained", ++ CHILD, ++ ); ++ assert!(stderr.contains("Cannot start a runtime from within a runtime")); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let (runtime, mut stream) = guarded_export( ++ futures::stream::iter(vec![Ok(test_batch())]), ++ test_schema(), ++ Arc::clone(&scanner_poison), ++ ); ++ let mut array = FFI_ArrowArray::empty(); ++ let status = runtime.block_on(async { unsafe { c_get_next(&mut stream, &mut array) } }); ++ assert_ne!(status, 0); ++ let message = unsafe { c_get_last_error(&mut stream) }.unwrap(); ++ assert!(message.contains("runtime"), "got: {message}"); ++ assert!(scanner_poison.load(Ordering::SeqCst)); ++ ++ let mut eos = FFI_ArrowArray::empty(); ++ assert_eq!(unsafe { c_get_next(&mut stream, &mut eos) }, 0); ++ assert!(eos.release.is_none()); ++ } ++ ++ #[test] ++ fn raw_stream_release_contains_drop_panic() { ++ const CHILD: &str = "LANCE_C_CHILD_STREAM_DROP_PANIC"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = assert_child_succeeds("raw_stream_release_contains_drop_panic", CHILD); ++ assert!(stderr.contains("simulated drop bug in stream cleanup")); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let (_runtime, mut stream) = ++ guarded_export(PanicOnDrop, test_schema(), Arc::clone(&scanner_poison)); ++ let release = stream.release.expect("release callback is NULL"); ++ unsafe { release(&mut stream) }; ++ assert!(stream.release.is_none()); ++ assert!(!scanner_poison.load(Ordering::SeqCst)); ++ } ++ ++ #[test] ++ fn guarded_in_memory_export_supports_raw_arrow_callbacks() { ++ let reader = ++ arrow::record_batch::RecordBatchIterator::new(vec![Ok(test_batch())], test_schema()); ++ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); ++ ++ let mut schema = FFI_ArrowSchema::empty(); ++ let get_schema = stream.get_schema.expect("get_schema callback is NULL"); ++ assert_eq!(unsafe { get_schema(&mut stream, &mut schema) }, 0); ++ assert!(schema.release.is_some()); ++ unsafe { schema.release.unwrap()(&mut schema) }; ++ ++ let get_next = stream.get_next.expect("get_next callback is NULL"); ++ let mut array = FFI_ArrowArray::empty(); ++ assert_eq!(unsafe { get_next(&mut stream, &mut array) }, 0); ++ assert!(array.release.is_some()); ++ unsafe { array.release.unwrap()(&mut array) }; ++ ++ let mut eos = FFI_ArrowArray::empty(); ++ assert_eq!(unsafe { get_next(&mut stream, &mut eos) }, 0); ++ assert!(eos.release.is_none()); ++ ++ let release = stream.release.expect("release callback is NULL"); ++ unsafe { release(&mut stream) }; ++ assert!(stream.release.is_none()); ++ } ++ ++ #[test] ++ fn guarded_in_memory_get_next_sanitizes_nul_error() { ++ const CHILD: &str = "LANCE_C_CHILD_READER_NUL_ERROR"; ++ if std::env::var(CHILD).is_err() { ++ assert_child_succeeds("guarded_in_memory_get_next_sanitizes_nul_error", CHILD); ++ return; ++ } ++ ++ let reader = arrow::record_batch::RecordBatchIterator::new( ++ vec![Err(ArrowError::ComputeError( ++ "ordinary reader error: bo\0om".into(), ++ ))], ++ test_schema(), ++ ); ++ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); ++ let message = raw_error_then_eos(&mut stream); ++ assert!(message.contains("bo\\0om"), "got: {message:?}"); ++ assert!(!message.contains('\0')); ++ } ++ ++ #[test] ++ fn guarded_in_memory_get_next_contains_error_display_panic() { ++ const CHILD: &str = "LANCE_C_CHILD_READER_DISPLAY_PANIC"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = assert_child_succeeds( ++ "guarded_in_memory_get_next_contains_error_display_panic", ++ CHILD, ++ ); ++ assert!(stderr.contains("simulated panic while formatting a reader error")); ++ return; ++ } ++ ++ let reader = arrow::record_batch::RecordBatchIterator::new( ++ vec![Err(ArrowError::ExternalError(Box::new(PanickingDisplay)))], ++ test_schema(), ++ ); ++ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); ++ let message = raw_error_then_eos(&mut stream); ++ assert!( ++ message.contains("simulated panic while formatting a reader error"), ++ "got: {message}" ++ ); ++ } ++ ++ #[test] ++ fn guarded_in_memory_get_next_contains_reader_panic() { ++ const CHILD: &str = "LANCE_C_CHILD_READER_NEXT_PANIC"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = ++ assert_child_succeeds("guarded_in_memory_get_next_contains_reader_panic", CHILD); ++ assert!(stderr.contains("simulated panic in materialized reader next")); ++ return; ++ } ++ ++ let reader = PanicOnReaderNext { ++ schema: test_schema(), ++ }; ++ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); ++ let message = raw_error_then_eos(&mut stream); ++ assert!( ++ message.contains("simulated panic in materialized reader next"), ++ "got: {message}" ++ ); ++ } ++ ++ #[test] ++ fn guarded_in_memory_rejects_nul_schema_before_callback_exposure() { ++ const CHILD: &str = "LANCE_C_CHILD_READER_NUL_SCHEMA"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = assert_child_succeeds( ++ "guarded_in_memory_rejects_nul_schema_before_callback_exposure", ++ CHILD, ++ ); ++ assert!(stderr.contains("NulError")); ++ return; ++ } ++ ++ let schema = Arc::new(Schema::new(vec![Field::new( ++ "field\0name", ++ DataType::Int32, ++ false, ++ )])); ++ let reader = arrow::record_batch::RecordBatchIterator::new( ++ Vec::>::new(), ++ schema, ++ ); ++ ++ let result = guarded_ffi_stream_from_reader(reader); ++ let error = result.expect_err("invalid schema must fail before export"); ++ assert!( ++ error.to_string().contains("panic exporting Arrow schema"), ++ "got: {error}" ++ ); ++ } ++ ++ #[test] ++ fn guarded_in_memory_release_contains_reader_drop_panic() { ++ const CHILD: &str = "LANCE_C_CHILD_READER_DROP_PANIC"; ++ if std::env::var(CHILD).is_err() { ++ let stderr = assert_child_succeeds( ++ "guarded_in_memory_release_contains_reader_drop_panic", ++ CHILD, ++ ); ++ assert!(stderr.contains("simulated panic in materialized reader drop")); ++ return; ++ } ++ ++ let reader = PanicOnReaderDrop { ++ schema: test_schema(), ++ }; ++ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); ++ let release = stream.release.expect("release callback is NULL"); ++ unsafe { release(&mut stream) }; ++ assert!(stream.release.is_none()); ++ } + } +diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs +index daa7425..17dc6c6 100644 +--- a/tests/c_api_test.rs ++++ b/tests/c_api_test.rs +@@ -993,7 +993,7 @@ fn test_scanner_scan_async() { + unsafe { + lance_scanner_scan_async( + scanner, +- on_complete, ++ Some(on_complete), + Arc::as_ptr(&pair_clone) as *mut std::ffi::c_void, + ); + lance_scanner_close(scanner); +@@ -1015,10 +1015,25 @@ fn test_scanner_scan_async() { + assert_eq!(total_rows, 5); + assert_eq!(captured.calls.load(AtomicOrdering::SeqCst), 1); + assert!(!captured.invalid_statistics.load(AtomicOrdering::SeqCst)); ++ unsafe { ++ lance_scanner_async_stream_free(result.stream_ptr.cast::()); ++ } + + unsafe { lance_dataset_close(ds) }; + } + ++#[test] ++fn test_scanner_async_stream_free_releases_stream_and_accepts_null() { ++ let (stream, drop_count) = make_counted_column_stream("value", vec![1]); ++ let stream = Box::into_raw(Box::new(stream)); ++ ++ unsafe { lance_scanner_async_stream_free(stream) }; ++ assert_eq!(drop_count.load(AtomicOrdering::SeqCst), 1); ++ ++ // Match the other close/free APIs: NULL is a no-op. ++ unsafe { lance_scanner_async_stream_free(ptr::null_mut()) }; ++} ++ + // =========================================================================== + // Additional tests + // =========================================================================== +@@ -1594,7 +1609,7 @@ fn test_async_scan_with_filter() { + unsafe { + lance_scanner_scan_async( + scanner, +- on_complete, ++ Some(on_complete), + Arc::as_ptr(&pair_clone) as *mut std::ffi::c_void, + ); + } +@@ -1609,6 +1624,9 @@ fn test_async_scan_with_filter() { + let ffi_stream = unsafe { &mut *(result.stream_ptr as *mut FFI_ArrowArrayStream) }; + let reader = unsafe { ArrowArrayStreamReader::from_raw(ffi_stream) }.unwrap(); + assert_eq!(reader.map(|r| r.unwrap().num_rows()).sum::(), 2); ++ unsafe { ++ lance_scanner_async_stream_free(result.stream_ptr.cast::()); ++ } + + unsafe { lance_scanner_close(scanner) }; + unsafe { lance_dataset_close(ds) }; +@@ -1641,7 +1659,7 @@ fn test_poll_next_basic() { + loop { + let mut batch: *mut LanceBatch = ptr::null_mut(); + let status = unsafe { +- lance_scanner_poll_next(scanner, test_waker, ptr::null_mut(), &mut batch) ++ lance_scanner_poll_next(scanner, Some(test_waker), ptr::null_mut(), &mut batch) + }; + match status { + LancePollStatus::Ready => { +@@ -3042,6 +3060,45 @@ fn test_index_segment_builder_owns_snapshot_and_is_single_use() { + } + } + ++#[test] ++fn test_index_segment_metadata_accessors_reject_null_handles() { ++ assert!(unsafe { lance_index_segment_metadata_name(ptr::null()) }.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ assert_eq!( ++ unsafe { lance_index_segment_metadata_dataset_version(ptr::null()) }, ++ 0 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ assert_eq!( ++ unsafe { lance_index_segment_metadata_index_version(ptr::null()) }, ++ -1 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ assert_eq!( ++ unsafe { lance_index_segment_metadata_index_type(ptr::null()) }, ++ -1 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ assert!(unsafe { lance_index_segment_metadata_index_details_type_url(ptr::null()) }.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ assert_eq!( ++ unsafe { lance_index_segment_metadata_field_count(ptr::null()) }, ++ 0 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ assert_eq!( ++ unsafe { lance_index_segment_metadata_fragment_count(ptr::null()) }, ++ 0 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++} ++ + #[test] + fn test_index_segment_metadata_parse_rejects_malformed_and_dangerous_input() { + use prost::Message; +@@ -5543,6 +5600,431 @@ fn test_fts_fuzzy() { + unsafe { lance_dataset_close(ds) }; + } + ++fn collect_context_fts_scores( ++ dataset: *const LanceDataset, ++ context: *const LanceFtsQueryContext, ++ segment_uuids: Option<&[[u8; 16]]>, ++) -> std::collections::HashMap { ++ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; ++ assert!(!scanner.is_null()); ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, ++ 0, ++ "{}", ++ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } ++ ); ++ if let Some(segment_uuids) = segment_uuids { ++ assert_eq!( ++ unsafe { ++ lance_scanner_set_fts_index_segments( ++ scanner, ++ segment_uuids.as_ptr().cast::(), ++ segment_uuids.len(), ++ ) ++ }, ++ 0 ++ ); ++ } ++ ++ let mut stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, ++ 0, ++ "{}", ++ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; ++ let mut scores = std::collections::HashMap::new(); ++ for batch in reader { ++ let batch = batch.unwrap(); ++ let ids = batch ++ .column_by_name("id") ++ .unwrap() ++ .as_any() ++ .downcast_ref::() ++ .unwrap(); ++ let batch_scores = batch ++ .column_by_name("_score") ++ .unwrap() ++ .as_any() ++ .downcast_ref::() ++ .unwrap(); ++ for row in 0..batch.num_rows() { ++ assert!( ++ scores ++ .insert(ids.value(row), batch_scores.value(row)) ++ .is_none() ++ ); ++ } ++ } ++ unsafe { lance_scanner_close(scanner) }; ++ scores ++} ++ ++fn load_fts_segment_uuids(uri: &str, column: &str) -> Vec<[u8; 16]> { ++ use lance::index::DatasetIndexExt; ++ use lance_index::IndexCriteria; ++ ++ lance_c::runtime::block_on(async { ++ let dataset = Dataset::open(uri).await.unwrap(); ++ let logical_index = dataset ++ .load_scalar_index(IndexCriteria::default().for_column(column).supports_fts()) ++ .await ++ .unwrap() ++ .unwrap(); ++ dataset ++ .load_indices_by_name(&logical_index.name) ++ .await ++ .unwrap() ++ .into_iter() ++ .map(|segment| *segment.uuid.as_bytes()) ++ .collect() ++ }) ++} ++ ++#[test] ++fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { ++ let (_tmp, uri) = create_test_dataset(); ++ let uri_c = c_str(&uri); ++ let column = c_str("name"); ++ let query = c_str("alice"); ++ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); ++ ++ let indexed_snapshot = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ indexed_snapshot, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ inverted_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ unsafe { lance_dataset_close(indexed_snapshot) }; ++ ++ let schema = Arc::new(Schema::new(vec![ ++ Field::new("id", DataType::Int32, false), ++ Field::new("name", DataType::Utf8, true), ++ ])); ++ let batch = RecordBatch::try_new( ++ schema.clone(), ++ vec![ ++ Arc::new(Int32Array::from(vec![6, 7])), ++ Arc::new(StringArray::from(vec!["alice", "alice alice"])), ++ ], ++ ) ++ .unwrap(); ++ append_batch(&uri, schema, batch); ++ ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ let strict = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(strict.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ let message = unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()) ++ .to_string_lossy() ++ .into_owned() ++ }; ++ assert!(message.contains("unindexed fragments"), "{message}"); ++ ++ let context = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::IndexOnly as i32, ++ ) ++ }; ++ assert!(!context.is_null()); ++ let segment_uuids = load_fts_segment_uuids(&uri, "name"); ++ assert_eq!(segment_uuids.len(), 1); ++ ++ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, ++ 0 ++ ); ++ // Scanner retains an Arc; closing the public handle does not invalidate it. ++ unsafe { lance_fts_query_context_close(context) }; ++ assert_eq!( ++ unsafe { ++ lance_scanner_set_fts_index_segments( ++ scanner, ++ segment_uuids.as_ptr().cast::(), ++ segment_uuids.len(), ++ ) ++ }, ++ 0 ++ ); ++ let mut stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, ++ 0 ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; ++ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert_eq!( ++ total_rows, 1, ++ "INDEX_ONLY must exclude both matching rows in the unindexed fragment" ++ ); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ ++#[test] ++fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() { ++ use lance::index::DatasetIndexExt; ++ use lance_index::optimize::OptimizeOptions; ++ ++ let (_tmp, uri) = create_test_dataset(); ++ let uri_c = c_str(&uri); ++ let column = c_str("name"); ++ let query = c_str("alice"); ++ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); ++ ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ dataset, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ inverted_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ unsafe { lance_dataset_close(dataset) }; ++ ++ let schema = Arc::new(Schema::new(vec![ ++ Field::new("id", DataType::Int32, false), ++ Field::new("name", DataType::Utf8, true), ++ ])); ++ append_batch( ++ &uri, ++ schema.clone(), ++ RecordBatch::try_new( ++ schema, ++ vec![ ++ Arc::new(Int32Array::from(vec![6, 7])), ++ Arc::new(StringArray::from(vec!["alice", "alice alice"])), ++ ], ++ ) ++ .unwrap(), ++ ); ++ lance_c::runtime::block_on(async { ++ let mut dataset = Dataset::open(&uri).await.unwrap(); ++ dataset ++ .optimize_indices(&OptimizeOptions::append()) ++ .await ++ .unwrap(); ++ }); ++ ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ let context = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!context.is_null(), "{}", unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() ++ }); ++ let segment_uuids = load_fts_segment_uuids(&uri, "name"); ++ assert_eq!(segment_uuids.len(), 2); ++ ++ let full_scores = collect_context_fts_scores(dataset, context, None); ++ assert_eq!(full_scores.len(), 3); ++ let mut split_scores = std::collections::HashMap::new(); ++ for segment_uuid in &segment_uuids { ++ for (id, score) in ++ collect_context_fts_scores(dataset, context, Some(std::slice::from_ref(segment_uuid))) ++ { ++ assert!(split_scores.insert(id, score).is_none()); ++ } ++ } ++ assert_eq!(split_scores.len(), full_scores.len()); ++ for (id, expected_score) in full_scores { ++ let actual_score = split_scores.get(&id).unwrap(); ++ assert!( ++ (actual_score - expected_score).abs() < 1e-6, ++ "id={id}, full={expected_score}, split={actual_score}" ++ ); ++ } ++ ++ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; ++ let duplicate_segments = [segment_uuids[0], segment_uuids[0]]; ++ assert_eq!( ++ unsafe { ++ lance_scanner_set_fts_index_segments( ++ scanner, ++ duplicate_segments.as_ptr().cast::(), ++ duplicate_segments.len(), ++ ) ++ }, ++ -1 ++ ); ++ assert!(unsafe { lance_scanner_set_fts_query_context(scanner, ptr::null()) } < 0); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ ++ let unknown_segment_scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(unknown_segment_scanner, context) }, ++ 0 ++ ); ++ let unknown_uuid = [0_u8; 16]; ++ assert_eq!( ++ unsafe { ++ lance_scanner_set_fts_index_segments(unknown_segment_scanner, unknown_uuid.as_ptr(), 1) ++ }, ++ 0, ++ "membership is validated against the attached context at scan time" ++ ); ++ let mut stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(unknown_segment_scanner, &mut stream) }, ++ -1 ++ ); ++ unsafe { lance_scanner_close(unknown_segment_scanner) }; ++ ++ let independently_reopened = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert!(!independently_reopened.is_null()); ++ assert_eq!( ++ unsafe { lance_dataset_version(independently_reopened) }, ++ unsafe { lance_dataset_version(dataset) }, ++ "the identity check must reject equal URI/version locator metadata" ++ ); ++ let reopened_scanner = ++ unsafe { lance_scanner_new(independently_reopened, ptr::null(), ptr::null()) }; ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(reopened_scanner, context) }, ++ -1, ++ "an independently opened dataset must not reuse the prepared context" ++ ); ++ let message = unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()) ++ .to_string_lossy() ++ .into_owned() ++ }; ++ assert!( ++ message.contains("same process-local dataset snapshot"), ++ "{message}" ++ ); ++ unsafe { lance_scanner_close(reopened_scanner) }; ++ unsafe { lance_dataset_close(independently_reopened) }; ++ ++ let old_snapshot = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 2) }; ++ assert!(!old_snapshot.is_null()); ++ let old_snapshot_scanner = unsafe { lance_scanner_new(old_snapshot, ptr::null(), ptr::null()) }; ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(old_snapshot_scanner, context) }, ++ -1, ++ "a context must not be attached to a different dataset version" ++ ); ++ unsafe { lance_scanner_close(old_snapshot_scanner) }; ++ unsafe { lance_dataset_close(old_snapshot) }; ++ ++ unsafe { lance_fts_query_context_close(context) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ ++#[test] ++fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { ++ let (_tmp, uri) = create_test_dataset(); ++ let uri_c = c_str(&uri); ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ let column = c_str("name"); ++ let query = c_str("alice"); ++ let empty = c_str(""); ++ ++ assert!( ++ unsafe { ++ lance_dataset_prepare_fts_query( ++ ptr::null(), ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ } ++ .is_null() ++ ); ++ assert!( ++ unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ empty.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ } ++ .is_null() ++ ); ++ assert!( ++ unsafe { lance_dataset_prepare_fts_query(dataset, column.as_ptr(), empty.as_ptr(), 0, 0) } ++ .is_null() ++ ); ++ assert!( ++ unsafe { lance_dataset_prepare_fts_query(dataset, column.as_ptr(), query.as_ptr(), 0, 99) } ++ .is_null() ++ ); ++ assert!( ++ unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 1, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ } ++ .is_null() ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ let message = unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()) ++ .to_string_lossy() ++ .into_owned() ++ }; ++ assert!( ++ message.contains("max_fuzzy_distance must be 0"), ++ "{message}" ++ ); ++ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_index_segments(scanner, ptr::null(), 1) }, ++ -1 ++ ); ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_index_segments(scanner, ptr::null(), 0) }, ++ 0 ++ ); ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_fts_query_context_close(ptr::null_mut()) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ + #[test] + fn test_nearest_after_fts_is_rejected() { + let (_tmp, uri) = create_vector_dataset(64, 8); +@@ -6418,18 +6900,17 @@ fn test_scanner_with_substrait_filter() { + + #[test] + fn test_scanner_substrait_filter_overrides_sql_filter() { +- // If both SQL and Substrait filters are set, Substrait wins (last write). ++ // If both primary filters are set, Substrait wins. + let (_tmp, uri) = create_test_dataset(); + let c_uri = c_str(&uri); + let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; + assert!(!ds.is_null()); + +- // Start with SQL filter "id < 0" (matches 0 rows). + let sql = c_str("id < 0"); + let scanner = unsafe { lance_scanner_new(ds, ptr::null(), sql.as_ptr()) }; + assert!(!scanner.is_null()); + +- // Override with Substrait filter "id > 3" (matches 2 rows). ++ // Attach Substrait filter "id > 3" (matches id=4 and id=5). + let bytes = substrait_id_gt_3(); + let rc = unsafe { lance_scanner_set_substrait_filter(scanner, bytes.as_ptr(), bytes.len()) }; + assert_eq!(rc, 0); +@@ -6446,6 +6927,81 @@ fn test_scanner_substrait_filter_overrides_sql_filter() { + unsafe { lance_dataset_close(ds) }; + } + ++#[test] ++fn test_scanner_additional_sql_filters_are_anded_with_substrait() { ++ let (_tmp, uri) = create_test_dataset(); ++ let c_uri = c_str(&uri); ++ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; ++ assert!(!ds.is_null()); ++ ++ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; ++ assert!(!scanner.is_null()); ++ ++ let bytes = substrait_id_gt_3(); ++ assert_eq!( ++ unsafe { lance_scanner_set_substrait_filter(scanner, bytes.as_ptr(), bytes.len()) }, ++ 0 ++ ); ++ for sql in [c_str("id < 6"), c_str("id < 5")] { ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, sql.as_ptr()) }, ++ 0 ++ ); ++ } ++ ++ let mut ffi_stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, ++ 0 ++ ); ++ ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); ++ let total_rows: usize = reader.map(|r| r.unwrap().num_rows()).sum(); ++ assert_eq!(total_rows, 1, "id > 3 AND id < 6 AND id < 5 matches id=4"); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_dataset_close(ds) }; ++} ++ ++#[test] ++fn test_scanner_additional_sql_filter_rejects_invalid_inputs() { ++ let (_tmp, uri) = create_test_dataset(); ++ let c_uri = c_str(&uri); ++ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; ++ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; ++ assert!(!scanner.is_null()); ++ ++ let filter = c_str("id > 3"); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(ptr::null_mut(), filter.as_ptr()) }, ++ -1 ++ ); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, ptr::null()) }, ++ -1 ++ ); ++ let empty = c_str(""); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, empty.as_ptr()) }, ++ -1 ++ ); ++ ++ let mut ffi_stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, ++ 0 ++ ); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, filter.as_ptr()) }, ++ -1, ++ "additional filters must be rejected after the scan starts" ++ ); ++ drop(unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap()); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_dataset_close(ds) }; ++} ++ + #[test] + fn test_scanner_set_substrait_filter_invalid_inputs() { + let (_tmp, uri) = create_test_dataset(); +@@ -10388,8 +10944,8 @@ fn test_add_columns_nulls_released_schema_rejected() { + #[test] + fn test_add_columns_nulls_non_utf8_format_rejected() { + // A non-NULL but non-UTF-8 top-level `format` must be rejected at the FFI +- // boundary rather than aborting via arrow-rs's `format().to_str().expect()` +- // under `panic = "abort"`. ++ // boundary rather than reaching arrow-rs's `format().to_str().expect()` ++ // and being downgraded from a precise InvalidArgument to Panic. + let (_tmp, uri) = create_large_dataset(2); + let c_uri = c_str(&uri); + let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; +diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp +index 3293bfb..b5d090a 100644 +--- a/tests/cpp/test_cpp_api.cpp ++++ b/tests/cpp/test_cpp_api.cpp +@@ -12,8 +12,11 @@ + + #include "lance/lance.hpp" + #include ++#include ++#include + #include + #include ++#include + #include + #include + #include +@@ -44,6 +47,29 @@ static void capture_scan_statistics( + captured->bytes_read = statistics->bytes_read; + } + ++struct AsyncScanCapture { ++ std::mutex mutex; ++ std::condition_variable ready; ++ bool completed = false; ++ int32_t status = -1; ++ ArrowArrayStream* stream = nullptr; ++}; ++ ++static void capture_async_scan( ++ void* callback_ctx, ++ int32_t status, ++ void* result) noexcept { ++ if (!callback_ctx) return; ++ auto* captured = static_cast(callback_ctx); ++ { ++ std::lock_guard lock(captured->mutex); ++ captured->status = status; ++ captured->stream = static_cast(result); ++ captured->completed = true; ++ } ++ captured->ready.notify_one(); ++} ++ + static void test_dataset_open(const std::string& uri) { + TEST(test_dataset_open); + +@@ -121,6 +147,47 @@ static void test_scanner_fluent(const std::string& uri) { + PASS(); + } + ++static void test_scanner_async_stream_ownership(const std::string& uri) { ++ TEST(test_scanner_async_stream_ownership); ++ ++ auto ds = lance::Dataset::open(uri); ++ auto scanner = ds.scan(); ++ AsyncScanCapture captured; ++ scanner.scan_async(capture_async_scan, &captured); ++ ++ ArrowArrayStream* stream = nullptr; ++ { ++ std::unique_lock lock(captured.mutex); ++ bool completed = captured.ready.wait_for( ++ lock, std::chrono::seconds(30), [&captured] { ++ return captured.completed; ++ }); ++ assert(completed && "async scan callback timed out"); ++ assert(captured.status == 0); ++ assert(captured.stream != nullptr); ++ stream = captured.stream; ++ } ++ ++ uint64_t total = 0; ++ while (true) { ++ ArrowArray array; ++ memset(&array, 0, sizeof(array)); ++ int rc = stream->get_next(stream, &array); ++ assert(rc == 0); ++ if (!array.release) break; ++ total += static_cast(array.length); ++ array.release(&array); ++ } ++ assert(total > 0); ++ ++ // This releases the stream contents (if still live) and the separate ++ // library-allocated outer structure. It is also explicitly NULL-safe. ++ lance::scanner_async_stream_free(stream); ++ lance::scanner_async_stream_free(nullptr); ++ ++ PASS(); ++} ++ + static void test_dataset_take(const std::string& uri) { + TEST(test_dataset_take); + +@@ -194,6 +261,15 @@ static void test_raii_cleanup(const std::string& uri) { + auto ds1 = lance::Dataset::open(uri); + auto ds2 = std::move(ds1); + assert(ds2.count_rows() > 0); ++ ++ bool moved_from_version_threw = false; ++ try { ++ (void)ds1.version(); ++ } catch (const lance::Error& e) { ++ moved_from_version_threw = true; ++ assert(e.code == LANCE_ERR_INVALID_ARGUMENT); ++ } ++ assert(moved_from_version_threw); + } + + PASS(); +@@ -832,6 +908,7 @@ int main(int argc, char** argv) { + test_dataset_open(uri); + test_dataset_schema(uri); + test_scanner_fluent(uri); ++ test_scanner_async_stream_ownership(uri); + test_dataset_take(uri); + test_dataset_take_rows(uri); + test_raii_cleanup(uri); +diff --git a/tests/panic_stream_guard.rs b/tests/panic_stream_guard.rs +index f4690c5..12efd0d 100644 +--- a/tests/panic_stream_guard.rs ++++ b/tests/panic_stream_guard.rs +@@ -40,8 +40,16 @@ + //! host. The guard's `Drop` detaches the inner stream and contains + //! cleanup. Runs in a child process, asserting a clean exit AND that + //! the destructor panic really fired (caught). ++//! ++//! 5. Regular errors containing NUL are sanitized before arrow-rs formats ++//! them inside `get_next`. ++//! 6. A panic from an external error's `Display` is caught, reported as one ++//! terminal stream error, and poisons the scanner. ++//! 7. An unexportable schema is rejected while still inside the Rust guard, ++//! before arrow-rs's non-unwinding `get_schema` callback is exposed. + + use std::ffi::CStr; ++use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; +@@ -49,7 +57,7 @@ use std::task::{Context, Poll}; + + use arrow::array::{Int32Array, RecordBatch}; + use arrow::datatypes::{DataType, Field, Schema, SchemaRef}; +-use arrow::ffi::FFI_ArrowArray; ++use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; + use arrow::ffi_stream::FFI_ArrowArrayStream; + use futures::Stream; + use lance_c::stream_guard::GuardedReader; +@@ -91,6 +99,17 @@ impl Stream for PanicOnSecondPoll { + /// destructor reached from the Arrow C `release` callback. + struct PanicOnDrop; + ++#[derive(Debug)] ++struct PanickingDisplay; ++ ++impl std::fmt::Display for PanickingDisplay { ++ fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ panic!("simulated panic while formatting a stream error") ++ } ++} ++ ++impl std::error::Error for PanickingDisplay {} ++ + impl Stream for PanicOnDrop { + type Item = lance_core::Result; + +@@ -112,6 +131,11 @@ unsafe fn c_get_next(stream: *mut FFI_ArrowArrayStream, array: *mut FFI_ArrowArr + unsafe { get_next(stream, array) } + } + ++unsafe fn c_get_schema(stream: *mut FFI_ArrowArrayStream, schema: *mut FFI_ArrowSchema) -> i32 { ++ let get_schema = unsafe { (*stream).get_schema }.expect("get_schema callback is NULL"); ++ unsafe { get_schema(stream, schema) } ++} ++ + unsafe fn c_get_last_error(stream: *mut FFI_ArrowArrayStream) -> Option { + let get_last_error = + unsafe { (*stream).get_last_error }.expect("get_last_error callback is NULL"); +@@ -241,6 +265,120 @@ fn guarded_stream_maps_panic_to_c_stream_error() { + ); + } + ++#[test] ++fn guarded_stream_sanitizes_nul_in_regular_error() { ++ if std::env::var("POC_CHILD_NUL_ERROR").is_err() { ++ let output = run_child( ++ "guarded_stream_sanitizes_nul_in_regular_error", ++ "POC_CHILD_NUL_ERROR", ++ ); ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ assert!( ++ output.status.success(), ++ "guarded child must exit cleanly, got status {:?}\nstderr:\n{stderr}", ++ output.status ++ ); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( ++ "ordinary error with a NUL: bo\0om".into(), ++ ))]); ++ let (_rt, mut ffi) = guarded_export(stream, Arc::clone(&scanner_poison)); ++ let mut array = FFI_ArrowArray::empty(); ++ ++ let rc = unsafe { c_get_next(&mut ffi, &mut array) }; ++ assert_ne!(rc, 0, "the ordinary stream error must reach Arrow C"); ++ let msg = unsafe { c_get_last_error(&mut ffi) }.expect("get_last_error returned NULL"); ++ assert!(msg.contains("bo\\0om"), "NUL must be escaped, got: {msg:?}"); ++ assert!( ++ !scanner_poison.load(Ordering::SeqCst), ++ "an ordinary stream error must not poison the scanner" ++ ); ++} ++ ++#[test] ++fn guarded_stream_catches_panicking_error_display() { ++ if std::env::var("POC_CHILD_DISPLAY_ERROR").is_err() { ++ let output = run_child( ++ "guarded_stream_catches_panicking_error_display", ++ "POC_CHILD_DISPLAY_ERROR", ++ ); ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ assert!( ++ output.status.success(), ++ "guarded child must exit cleanly, got status {:?}\nstderr:\n{stderr}", ++ output.status ++ ); ++ assert!( ++ stderr.contains("simulated panic while formatting a stream error"), ++ "the formatting panic must have fired and been caught\nstderr:\n{stderr}" ++ ); ++ return; ++ } ++ ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( ++ Box::new(PanickingDisplay), ++ ))]); ++ let (_rt, mut ffi) = guarded_export(stream, Arc::clone(&scanner_poison)); ++ let mut array = FFI_ArrowArray::empty(); ++ ++ let rc = unsafe { c_get_next(&mut ffi, &mut array) }; ++ assert_ne!(rc, 0, "the caught panic must reach Arrow C as an error"); ++ let msg = unsafe { c_get_last_error(&mut ffi) }.expect("get_last_error returned NULL"); ++ assert!( ++ msg.contains("simulated panic while formatting a stream error"), ++ "panic message should propagate to get_last_error, got: {msg}" ++ ); ++ assert!( ++ scanner_poison.load(Ordering::SeqCst), ++ "a formatting panic must poison the owning scanner" ++ ); ++} ++ ++#[test] ++fn guarded_stream_rejects_nul_schema_before_arrow_callback() { ++ if std::env::var("POC_CHILD_NUL_SCHEMA").is_err() { ++ let output = run_child( ++ "guarded_stream_rejects_nul_schema_before_arrow_callback", ++ "POC_CHILD_NUL_SCHEMA", ++ ); ++ let stderr = String::from_utf8_lossy(&output.stderr); ++ assert!( ++ output.status.success(), ++ "schema validation must fail before Arrow's callback can abort, got status {:?}\nstderr:\n{stderr}", ++ output.status ++ ); ++ return; ++ } ++ ++ let schema = Arc::new(Schema::new(vec![Field::new( ++ "field\0name", ++ DataType::Int32, ++ false, ++ )])); ++ let rt = tokio::runtime::Runtime::new().unwrap(); ++ let scanner_poison = Arc::new(AtomicBool::new(false)); ++ let outcome = catch_unwind(AssertUnwindSafe(|| { ++ let reader = GuardedReader::new( ++ futures::stream::empty::>(), ++ schema, ++ rt.handle().clone(), ++ scanner_poison, ++ ); ++ let mut ffi = FFI_ArrowArrayStream::new(Box::new(reader)); ++ let mut ffi_schema = FFI_ArrowSchema::empty(); ++ let rc = unsafe { c_get_schema(&mut ffi, &mut ffi_schema) }; ++ panic!("invalid schema reached Arrow callback and returned rc={rc}"); ++ })); ++ assert!( ++ outcome.is_err(), ++ "invalid schema must be rejected while the Rust FFI guard can still catch it" ++ ); ++} ++ + /// A `get_next` call made from a thread that is currently driving a Tokio + /// runtime (inside `Runtime::block_on` or a spawned task — a merely + /// `enter()`ed context does not trip tokio's check) makes `Handle::block_on` From 7873e14028dfb2d532981c3ee5d96e1b829a4df1 Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Fri, 28 Aug 2026 17:04:10 +0800 Subject: [PATCH 2/7] fts --- .../format_v2/lance/lance_reader_helper.cpp | 42 ++ be/src/format_v2/lance/lance_reader_helper.h | 8 + be/src/format_v2/table/lance_reader.cpp | 418 ++++++++++++------ be/src/format_v2/table/lance_reader.h | 15 +- .../catalog/BuiltinTableValuedFunctions.java | 4 +- .../lance/LanceExternalCatalog.java | 2 +- .../datasource/lance/LanceExternalTable.java | 4 +- .../lance/LanceIndexSegmentInfo.java | 27 +- .../datasource/lance/LanceMetadataLoader.java | 15 +- .../lance/source/IndexSegmentSplitPlan.java | 2 +- .../lance/source/LanceScanNode.java | 257 ++++++++--- .../materialize/MaterializeProbeVisitor.java | 11 +- .../rules/analysis/BindExpression.java | 39 +- ...PushDownFilterThroughVectorSearchTopN.java | 18 +- .../functions/table/FullTextSearch.java | 49 ++ .../FullTextSearchTableValuedFunction.java | 113 +++++ ...anceExternalSearchTableValuedFunction.java | 360 +++++++++++++++ .../tablefunction/TableValuedFunctionIf.java | 2 + .../VectorSearchTableValuedFunction.java | 287 ++---------- .../lance/source/LanceScanNodeTest.java | 30 +- gensrc/thrift/PlanNodes.thrift | 21 +- 21 files changed, 1215 insertions(+), 509 deletions(-) create mode 100644 fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/FullTextSearch.java create mode 100644 fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java create mode 100644 fe/fe-core/src/main/java/org/apache/doris/tablefunction/LanceExternalSearchTableValuedFunction.java diff --git a/be/src/format_v2/lance/lance_reader_helper.cpp b/be/src/format_v2/lance/lance_reader_helper.cpp index 5d8013c102f6be..446a4b40179e4d 100644 --- a/be/src/format_v2/lance/lance_reader_helper.cpp +++ b/be/src/format_v2/lance/lance_reader_helper.cpp @@ -207,6 +207,48 @@ size_t lance_vector_element_width(TVectorElementType::type type) { return 0; } +Status parse_fragment_ids(const TLanceFileDesc& lance_params, std::vector* fragment_ids) { + DORIS_CHECK(fragment_ids != nullptr); + fragment_ids->clear(); + if (!lance_params.__isset.fragment_ids || lance_params.fragment_ids.empty()) { + return Status::OK(); + } + fragment_ids->reserve(lance_params.fragment_ids.size()); + for (const auto fragment_id : lance_params.fragment_ids) { + if (fragment_id < 0) { + return Status::InvalidArgument("Lance fragment id must be non-negative: {}", + fragment_id); + } + fragment_ids->emplace_back(static_cast(fragment_id)); + } + return Status::OK(); +} + +Status parse_index_segment_uuids(const TLanceFileDesc& lance_params, + std::vector* segment_uuids, size_t* segment_count) { + DORIS_CHECK(segment_uuids != nullptr); + DORIS_CHECK(segment_count != nullptr); + segment_uuids->clear(); + *segment_count = 0; + if (!lance_params.__isset.index_segment_uuids || lance_params.index_segment_uuids.empty()) { + return Status::OK(); + } + constexpr size_t UUID_SIZE = 16; + if (lance_params.index_segment_uuids.size() > std::numeric_limits::max() / UUID_SIZE) { + return Status::InvalidArgument("too many Lance index segment UUIDs"); + } + segment_uuids->reserve(lance_params.index_segment_uuids.size() * UUID_SIZE); + for (const auto& uuid : lance_params.index_segment_uuids) { + if (uuid.size() != UUID_SIZE) { + return Status::InvalidArgument("Lance index segment UUID must contain 16 bytes, got {}", + uuid.size()); + } + segment_uuids->insert(segment_uuids->end(), uuid.begin(), uuid.end()); + } + *segment_count = lance_params.index_segment_uuids.size(); + return Status::OK(); +} + Status convert_arrow_schema_to_doris(const std::shared_ptr& arrow_schema, std::vector* column_names, std::vector* column_types) { diff --git a/be/src/format_v2/lance/lance_reader_helper.h b/be/src/format_v2/lance/lance_reader_helper.h index 15cf8ee61844d4..689e4f4fd9f492 100644 --- a/be/src/format_v2/lance/lance_reader_helper.h +++ b/be/src/format_v2/lance/lance_reader_helper.h @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include #include @@ -38,6 +39,7 @@ class Schema; namespace doris::format::lance { inline constexpr std::string_view LANCE_DISTANCE_COLUMN = "_distance"; +inline constexpr std::string_view LANCE_SCORE_COLUMN = "_score"; inline constexpr std::string_view LANCE_ROW_ID_COLUMN = "_rowid"; inline constexpr const char* LANCE_READER_PROFILE = "LanceReader"; @@ -55,6 +57,12 @@ struct LanceBatchDeleter { size_t lance_vector_element_width(TVectorElementType::type type); +// Validate and convert the fragment and index-segment identifiers carried by the FE into the +// unsigned and packed representations expected by lance-c. +Status parse_fragment_ids(const TLanceFileDesc& lance_params, std::vector* fragment_ids); +Status parse_index_segment_uuids(const TLanceFileDesc& lance_params, + std::vector* segment_uuids, size_t* segment_count); + // Convert every top-level field without discarding unsupported columns. Malformed schemas still // return an error and leave both output vectors unchanged. DataTypeNothing is the local sentinel // for a valid Arrow field whose logical type Doris does not support. diff --git a/be/src/format_v2/table/lance_reader.cpp b/be/src/format_v2/table/lance_reader.cpp index f27b6dd9e3aa7e..c46700fd0fe352 100644 --- a/be/src/format_v2/table/lance_reader.cpp +++ b/be/src/format_v2/table/lance_reader.cpp @@ -93,6 +93,7 @@ Status LanceTableReader::init(TableReadOptions&& options) { DORIS_CHECK(_runtime_state != nullptr); DORIS_CHECK(_scanner_profile != nullptr); DORIS_CHECK(_scan_params != nullptr); + RETURN_IF_ERROR(_resolve_search_kind()); _ctz = _runtime_state->timezone_obj(); const auto& lance_scan_params = _scan_params->lance_scan_params; @@ -148,16 +149,29 @@ Status LanceTableReader::init(TableReadOptions&& options) { ADD_CHILD_TIMER_WITH_LEVEL(_scanner_profile, "LanceIVFPartitionRankingTime", LANCE_READER_PROFILE, 1)}, }; - _vector_search = _scan_params->__isset.lance_scan_params && - lance_scan_params.__isset.external_search_request; - if (_vector_search) { + if (_search_kind != SearchKind::NORMAL) { RETURN_IF_ERROR(_validate_external_search_request()); const auto& request = lance_scan_params.external_search_request; - const auto& vector = request.search_query.vector_search; - _scanner_profile->add_info_string("LanceTopK", std::to_string(vector.top_k)); - _scanner_profile->add_info_string("LanceOffset", std::to_string(vector.offset)); - _scanner_profile->add_info_string("LanceTopKPlusOffset", - std::to_string(vector.top_k + vector.offset)); + 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"); + } + _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)); _planned_index_segment_count = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, "LancePlannedIndexSegmentCount", TUnit::UNIT, LANCE_READER_PROFILE, 1); @@ -185,9 +199,9 @@ Status LanceTableReader::init(TableReadOptions&& options) { return Status::InvalidArgument("Lance projected column '{}' has no type", column.name); } if (column.name.starts_with(BeConsts::GLOBAL_ROWID_COL)) { - if (!_vector_search) { + if (_search_kind == SearchKind::NORMAL) { return Status::NotSupported( - "Lance global row id is currently supported only for vector search"); + "Lance global row id is currently supported only for external search"); } if (_global_rowid_output_idx.has_value()) { return Status::InvalidArgument("duplicate Lance global row id projected column: {}", @@ -204,13 +218,20 @@ Status LanceTableReader::init(TableReadOptions&& options) { if (!_output_name_to_idx.emplace(column.name, idx).second) { return Status::InvalidArgument("duplicate Lance projected column: {}", column.name); } - if (_vector_search && column.name == LANCE_DISTANCE_COLUMN) { + if (_search_kind == SearchKind::VECTOR && column.name == LANCE_DISTANCE_COLUMN) { const auto distance_type = remove_nullable(column.type); if (distance_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()); } + } else if (_search_kind == SearchKind::FULL_TEXT && column.name == LANCE_SCORE_COLUMN) { + const auto score_type = remove_nullable(column.type); + if (score_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()); + } } } return Status::OK(); @@ -324,7 +345,9 @@ Status LanceTableReader::read_by_row_ids(const TFileRangeDesc& range, } SCOPED_TIMER(_row_id_fetch_total_time); - RETURN_IF_ERROR(_ensure_dataset_open(range)); + // 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 columns; columns.reserve(_projected_columns.size() + 1); for (const auto& column : _projected_columns) { @@ -399,8 +422,31 @@ Status LanceTableReader::close() { 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 vector_search(), but this reader consumes a deserialized + // 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); @@ -409,7 +455,7 @@ Status LanceTableReader::_validate_external_search_request() const { DORIS_CHECK(lance_scan_params.__isset.external_search_request); if (lance_scan_params.__isset.lance_substrait_filter) { return Status::InvalidArgument( - "Lance vector search cannot combine its pre-search filter with " + "Lance external search cannot combine its pre-search filter with " "lance_substrait_filter"); } @@ -418,58 +464,87 @@ Status LanceTableReader::_validate_external_search_request() const { return Status::NotSupported("unsupported external search schema version: {}", request.schema_version); } - 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"); - } - if (has_full_text) { - return Status::NotSupported("Lance Format V2 reader does not yet support full-text search"); - } - - 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(query_vector.element_type)); - } - const auto dimension = static_cast(query_vector.dimension); - if (dimension > std::numeric_limits::max() / element_width || - query_vector.values.size() != dimension * element_width) { - return Status::InvalidArgument( - "Lance query vector byte size {} does not match dimension {} and element width {}", - query_vector.values.size(), 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"); - } + DORIS_CHECK(request.__isset.search_query); + DORIS_CHECK(_search_kind != SearchKind::NORMAL); constexpr auto UINT32_MAX_VALUE = static_cast(std::numeric_limits::max()); - 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"); + 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(query_vector.element_type)); + } + const auto dimension = static_cast(query_vector.dimension); + if (dimension > std::numeric_limits::max() / element_width || + query_vector.values.size() != dimension * element_width) { + return Status::InvalidArgument( + "Lance query vector byte size {} does not match dimension {} and element width " + "{}", + query_vector.values.size(), 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"); + } + } 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 (request.__isset.vector_search_options) { + return Status::InvalidArgument( + "Lance full-text search cannot set vector_search_options"); + } } if (request.__isset.search_filter) { @@ -487,7 +562,7 @@ Status LanceTableReader::_validate_external_search_request() const { } } - if (request.__isset.vector_search_options) { + if (_search_kind == SearchKind::VECTOR && request.__isset.vector_search_options) { const auto& options = request.vector_search_options; if (options.__isset.nprobes && options.nprobes <= 0) { return Status::InvalidArgument("Lance nprobes must be positive"); @@ -502,7 +577,8 @@ Status LanceTableReader::_validate_external_search_request() const { return Status::OK(); } -Status LanceTableReader::_ensure_dataset_open(const TFileRangeDesc& range) { +Status LanceTableReader::_ensure_dataset_open(const TFileRangeDesc& range, + bool prepare_fts_context) { DatasetKey key; RETURN_IF_ERROR(_dataset_key(range, &key)); if (_dataset == nullptr) { @@ -512,6 +588,10 @@ Status LanceTableReader::_ensure_dataset_open(const TFileRangeDesc& range) { 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(); } @@ -535,6 +615,30 @@ Status LanceTableReader::_open_dataset(const DatasetKey& key) { 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. + _fts_query_context = lance_dataset_prepare_fts_query(_dataset, full_text.column.c_str(), + full_text.query.c_str(), 0, coverage_mode); + if (_fts_query_context == nullptr) { + return lance_error("prepare Lance FTS query context"); + } + return Status::OK(); +} + Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { SCOPED_TIMER(_scanner_configure_time); std::vector columns; @@ -546,25 +650,26 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { const auto& column = _projected_columns[idx]; columns.emplace_back(column.name.c_str()); } - if (_vector_search && columns.empty()) { + if (_search_kind != SearchKind::NORMAL && columns.empty()) { // Keep an explicit empty user projection from becoming `nullptr`, which means all dataset - // columns to lance-c. nearest() already returns this optional system column. - columns.emplace_back(LANCE_DISTANCE_COLUMN.data()); + // 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 runtime_filter_sql; - if (_vector_search) { + if (_search_kind == SearchKind::NORMAL) { + runtime_filter_sql = + get_or_create_lance_runtime_filter_sql(_conjuncts, _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; } - } else { - runtime_filter_sql = - get_or_create_lance_runtime_filter_sql(_conjuncts, _runtime_filter_cache); } LanceScanner* scanner = lance_scanner_new(_dataset, columns.size() == 1 ? nullptr : columns.data(), @@ -606,79 +711,65 @@ Status LanceTableReader::_open_scanner(const TFileRangeDesc& range) { } const auto& lance_params = range.table_format_params.lance_params; - if (lance_params.__isset.fragment_ids && !lance_params.fragment_ids.empty()) { - const auto& thrift_ids = lance_params.fragment_ids; - std::vector fragment_ids; - fragment_ids.reserve(thrift_ids.size()); - for (const auto fragment_id : thrift_ids) { - fragment_ids.emplace_back(static_cast(fragment_id)); - } - if (lance_scanner_set_fragment_ids(scanner, fragment_ids.data(), fragment_ids.size()) != - 0) { - return lance_error("set Lance scanner fragment ids"); - } - } - if (lance_params.__isset.index_segment_uuids && !lance_params.index_segment_uuids.empty()) { - if (!_vector_search) { - return Status::InvalidArgument( - "Lance index segments are only supported for vector search splits"); - } - constexpr size_t UUID_SIZE = 16; - if (lance_params.index_segment_uuids.size() > - std::numeric_limits::max() / UUID_SIZE) { - return Status::InvalidArgument("too many Lance index segment UUIDs"); - } - std::vector segment_uuids; - segment_uuids.reserve(lance_params.index_segment_uuids.size() * UUID_SIZE); - for (const auto& uuid : lance_params.index_segment_uuids) { - if (uuid.size() != UUID_SIZE) { - return Status::InvalidArgument( - "Lance index segment UUID must contain 16 bytes, got {}", uuid.size()); - } - segment_uuids.insert(segment_uuids.end(), uuid.begin(), uuid.end()); - } - if (lance_scanner_set_index_segments(scanner, segment_uuids.data(), - lance_params.index_segment_uuids.size()) != 0) { - return lance_error("set Lance scanner index segments"); - } - } - // Ordinary scans may carry a pushed-down LIMIT. The FE only sets it when all predicates are - // pushed into Lance, so the scanner can safely stop after `limit` rows. Vector search manages - // its own top_k limit in _configure_vector_search, so skip it here. - if (!_vector_search && lance_params.__isset.limit && lance_params.limit > 0) { - if (lance_scanner_set_limit(scanner, lance_params.limit) != 0) { - return lance_error("set Lance scanner limit"); - } - } - if (_vector_search) { - // Distributed vector search always restricts each scanner to an explicit fragment set. - // Tell Lance that this fragment scan is the input to nearest() before installing the - // query. The same prefilter path also applies the TVF search filter, when present. - if (lance_scanner_set_prefilter(scanner, true) != 0) { - return lance_error("enable Lance vector prefilter"); - } - RETURN_IF_ERROR(_configure_vector_search(scanner)); - const int64_t fragment_count = - lance_params.__isset.fragment_ids - ? static_cast(lance_params.fragment_ids.size()) - : 0; - if (lance_params.__isset.index_segment_uuids && !lance_params.index_segment_uuids.empty()) { - COUNTER_UPDATE(_planned_index_segment_count, - static_cast(lance_params.index_segment_uuids.size())); - COUNTER_UPDATE(_planned_indexed_fragment_count, fragment_count); - } else { - COUNTER_UPDATE(_planned_flat_search_fragment_count, fragment_count); - } + 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_vector_search(LanceScanner* scanner) const { +Status LanceTableReader::_configure_normal_scan(LanceScanner* scanner, + const TLanceFileDesc& lance_params) const { + DORIS_CHECK(scanner != nullptr); + std::vector 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"); + } + if (lance_params.__isset.index_segment_uuids && !lance_params.index_segment_uuids.empty()) { + return Status::InvalidArgument("normal Lance scan cannot contain index segment UUIDs"); + } + // 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 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 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) { + return lance_error("enable Lance vector prefilter"); + } const auto& lance_scan_params = _scan_params->lance_scan_params; DORIS_CHECK(lance_scan_params.__isset.external_search_request); const auto& request = lance_scan_params.external_search_request; @@ -790,6 +881,46 @@ Status LanceTableReader::_configure_vector_search(LanceScanner* scanner) const { if (lance_scanner_set_limit(scanner, vector.top_k) != 0) { return lance_error("set Lance vector result limit"); } + const auto fragment_count = static_cast(fragment_ids.size()); + if (segment_count > 0) { + COUNTER_UPDATE(_planned_index_segment_count, static_cast(segment_count)); + COUNTER_UPDATE(_planned_indexed_fragment_count, fragment_count); + } else { + COUNTER_UPDATE(_planned_flat_search_fragment_count, fragment_count); + } + return Status::OK(); +} + +Status LanceTableReader::_configure_full_text_search(LanceScanner* scanner, + const TLanceFileDesc& lance_params) const { + DORIS_CHECK(scanner != nullptr); + DORIS_CHECK(_fts_query_context != nullptr); + DORIS_CHECK(_scan_params != nullptr); + // FTS fragment IDs describe the selected segment's coverage for planning and profiling. They + // are not installed as a generic fragment filter because lance-c rejects combining one with a + // prepared FTS context; the segment UUID is the execution boundary. + std::vector fragment_ids; + RETURN_IF_ERROR(parse_fragment_ids(lance_params, &fragment_ids)); + std::vector segment_uuids; + size_t segment_count = 0; + RETURN_IF_ERROR(parse_index_segment_uuids(lance_params, &segment_uuids, &segment_count)); + if (segment_count == 0) { + return Status::InvalidArgument( + "Lance full-text search split requires at least one FTS index segment UUID"); + } + const auto& full_text = + _scan_params->lance_scan_params.external_search_request.search_query.full_text_search; + if (lance_scanner_set_fts_query_context(scanner, _fts_query_context) != 0) { + return lance_error("attach Lance FTS query context"); + } + if (lance_scanner_set_fts_index_segments(scanner, segment_uuids.data(), segment_count) != 0) { + return lance_error("set Lance FTS scanner index segments"); + } + if (lance_scanner_set_limit(scanner, full_text.top_k + full_text.offset) != 0) { + return lance_error("set Lance FTS scanner candidate limit"); + } + COUNTER_UPDATE(_planned_index_segment_count, static_cast(segment_count)); + COUNTER_UPDATE(_planned_indexed_fragment_count, static_cast(fragment_ids.size())); return Status::OK(); } @@ -878,6 +1009,10 @@ void LanceTableReader::_close_scanner() { } void LanceTableReader::_close_dataset() { + if (_fts_query_context != nullptr) { + lance_fts_query_context_close(_fts_query_context); + _fts_query_context = nullptr; + } if (_dataset != nullptr) { lance_dataset_close(_dataset); _dataset = nullptr; @@ -972,9 +1107,10 @@ Status LanceTableReader::_fill_block_from_record_batch( } const auto output_it = _output_name_to_idx.find(field->name()); if (output_it == _output_name_to_idx.end()) { - if (_vector_search && field->name() == LANCE_DISTANCE_COLUMN) { - // Lance currently auto-projects _distance for nearest queries. It is valid for - // Doris slot pruning to omit that optional result column. + if ((_search_kind == SearchKind::VECTOR && field->name() == LANCE_DISTANCE_COLUMN) || + (_search_kind == SearchKind::FULL_TEXT && field->name() == LANCE_SCORE_COLUMN)) { + // Lance auto-projects the generated search result column. It is valid for Doris + // slot pruning to omit that optional result column. continue; } return Status::InternalError("Lance returned unknown column '{}'", field->name()); diff --git a/be/src/format_v2/table/lance_reader.h b/be/src/format_v2/table/lance_reader.h index a554f19533c780..f3c334fbd785ea 100644 --- a/be/src/format_v2/table/lance_reader.h +++ b/be/src/format_v2/table/lance_reader.h @@ -34,6 +34,7 @@ struct LanceBatch; struct LanceDataset; +struct LanceFtsQueryContext; struct LanceScanner; namespace doris { @@ -79,11 +80,17 @@ class LanceTableReader final : public TableReader { bool operator==(const DatasetKey&) const = default; }; + Status _resolve_search_kind(); Status _validate_external_search_request() const; - Status _ensure_dataset_open(const TFileRangeDesc& range); + Status _ensure_dataset_open(const TFileRangeDesc& range, bool prepare_fts_context = true); Status _open_dataset(const DatasetKey& key); + Status _prepare_fts_query_context(); Status _open_scanner(const TFileRangeDesc& range); - Status _configure_vector_search(LanceScanner* scanner) const; + Status _configure_normal_scan(LanceScanner* scanner, const TLanceFileDesc& lance_params) const; + Status _configure_vector_search(LanceScanner* scanner, + const TLanceFileDesc& lance_params) const; + Status _configure_full_text_search(LanceScanner* scanner, + const TLanceFileDesc& lance_params) const; // Keep lance-c's anonymous statistics typedef out of this header. _open_scanner installs the // strongly typed C callback adapter before forwarding the borrowed value here. static void _collect_scan_statistics(void* callback_ctx, const void* opaque_statistics); @@ -120,7 +127,9 @@ class LanceTableReader final : public TableReader { RuntimeProfile::Counter* _index_comparisons = nullptr; std::unordered_map _lance_count_metrics; std::unordered_map _lance_time_metrics; - bool _vector_search = false; + LanceFtsQueryContext* _fts_query_context = nullptr; + enum class SearchKind { NORMAL, VECTOR, FULL_TEXT }; + SearchKind _search_kind = SearchKind::NORMAL; bool _eof = false; }; diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinTableValuedFunctions.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinTableValuedFunctions.java index 06b7242d161875..741710801f3b23 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinTableValuedFunctions.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/BuiltinTableValuedFunctions.java @@ -23,6 +23,7 @@ import org.apache.doris.nereids.trees.expressions.functions.table.File; import org.apache.doris.nereids.trees.expressions.functions.table.Frontends; import org.apache.doris.nereids.trees.expressions.functions.table.FrontendsDisks; +import org.apache.doris.nereids.trees.expressions.functions.table.FullTextSearch; import org.apache.doris.nereids.trees.expressions.functions.table.GroupCommit; import org.apache.doris.nereids.trees.expressions.functions.table.Hdfs; import org.apache.doris.nereids.trees.expressions.functions.table.Http; @@ -77,7 +78,8 @@ public class BuiltinTableValuedFunctions implements FunctionHelper { tableValued(ParquetKvMetadata.class, "parquet_kv_metadata"), tableValued(ParquetBloomProbe.class, "parquet_bloom_probe"), tableValued(CdcStream.class, "cdc_stream"), - tableValued(VectorSearch.class, "vector_search") + tableValued(VectorSearch.class, "vector_search"), + tableValued(FullTextSearch.class, "full_text_search") ); public static final BuiltinTableValuedFunctions INSTANCE = new BuiltinTableValuedFunctions(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java index c2fa3d1a6ee8a4..33ebc39a30ef85 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalCatalog.java @@ -291,7 +291,7 @@ public LanceTableMetadata loadTableMetadata(String dbName, String tableName) { return loadTableMetadata(dbName, tableName, Optional.empty(), false); } - public LanceTableMetadata loadTableMetadataForVectorSearch(String dbName, String tableName) { + public LanceTableMetadata loadTableMetadataForSearch(String dbName, String tableName) { return loadTableMetadata(dbName, tableName, Optional.empty(), true); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalTable.java index acba68b63fcbe5..0d1f124fce6f88 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceExternalTable.java @@ -66,8 +66,8 @@ public LanceTableMetadata loadMetadata() { return ((LanceExternalCatalog) catalog).loadTableMetadata(db.getRemoteName(), remoteName); } - public LanceTableMetadata loadMetadataForVectorSearch() { - return ((LanceExternalCatalog) catalog).loadTableMetadataForVectorSearch( + public LanceTableMetadata loadMetadataForSearch() { + return ((LanceExternalCatalog) catalog).loadTableMetadataForSearch( db.getRemoteName(), remoteName); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexSegmentInfo.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexSegmentInfo.java index 7fb373f9246035..f0f1667cbee573 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexSegmentInfo.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceIndexSegmentInfo.java @@ -17,27 +17,32 @@ package org.apache.doris.datasource.lance; +import org.lance.index.IndexType; + import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; -/** Immutable metadata for one physical segment of a logical Lance vector index. */ +/** Immutable metadata for one physical segment of a logical Lance search index. */ public final class LanceIndexSegmentInfo { private final UUID uuid; private final String indexName; private final List fieldIds; private final List fragmentIds; + private final IndexType indexType; private final String metric; public LanceIndexSegmentInfo(UUID uuid, String indexName, List fieldIds, - List fragmentIds, String metric) { + List fragmentIds, IndexType indexType, String metric) { this.uuid = uuid; this.indexName = indexName; this.fieldIds = Collections.unmodifiableList(new ArrayList<>(fieldIds)); this.fragmentIds = fragmentIds == null ? null : Collections.unmodifiableList(new ArrayList<>(fragmentIds)); + this.indexType = Objects.requireNonNull(indexType, "indexType must not be null"); this.metric = metric; } @@ -56,14 +61,26 @@ public List getFieldIds() { /** * Returns the fragment bitmap recorded in the manifest. * - *

Legacy index segments may not have a bitmap. Callers must not infer coverage from the - * segment's dataset version in that case. + *

Index segments without a fragment bitmap have unknown coverage. Callers must not infer + * coverage from the segment's dataset version in that case. */ public Optional> getFragmentIds() { return Optional.ofNullable(fragmentIds); } - /** Returns the normalized Lance metric name, or an empty optional for legacy metadata. */ + public IndexType getIndexType() { + return indexType; + } + + public boolean isVectorIndex() { + return indexType.getValue() >= IndexType.VECTOR.getValue(); + } + + public boolean isFullTextIndex() { + return indexType == IndexType.INVERTED; + } + + /** Returns the normalized Lance metric name when the index metadata supplies one. */ public Optional getMetric() { return Optional.ofNullable(metric); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java index 9c4803af7100d6..a3c27e7b6bbdf6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/LanceMetadataLoader.java @@ -72,7 +72,7 @@ public static LanceTableMetadata loadLatest(String datasetUri, datasetUri, lanceStorageOptions, OptionalLong.empty(), allocator, false); } - /** Loads the latest fixed snapshot together with vector index segment coverage. */ + /** Loads the latest fixed snapshot together with search-index segment coverage. */ public static LanceTableMetadata loadLatestWithIndexSegments( String datasetUri, Map lanceStorageOptions, BufferAllocator allocator) throws Exception { return loadInternal( @@ -109,7 +109,7 @@ private static LanceTableMetadata loadInternal(String datasetUri, Map lanceFieldIds = loadIndexSegments ? loadTopLevelFieldIds(dataset) : Collections.emptyMap(); List indexSegments = loadIndexSegments - ? loadVectorIndexSegments(dataset) : Collections.emptyList(); + ? loadSearchIndexSegments(dataset) : Collections.emptyList(); return loadIndexSegments ? LanceTableMetadata.withIndexSegments(datasetUri, resolvedVersion, dataset.getSchema(), fragments, lanceFieldIds, @@ -134,12 +134,13 @@ private static Map loadTopLevelFieldIds(Dataset dataset) { return result; } - private static List loadVectorIndexSegments(Dataset dataset) { + private static List loadSearchIndexSegments(Dataset dataset) { List result = new ArrayList<>(); for (IndexDescription description : dataset.describeIndices()) { String metric = parseMetric(description.getDetailsJson()); for (Index segment : description.getSegments()) { - if (segment.indexType() == null || segment.indexType().getValue() < 100) { + if (segment.indexType() == null || (segment.indexType().getValue() < 100 + && segment.indexType() != org.lance.index.IndexType.INVERTED)) { continue; } List fragmentIds = segment.fragments() @@ -152,7 +153,7 @@ private static List loadVectorIndexSegments(Dataset datas }) .orElse(null); result.add(new LanceIndexSegmentInfo(segment.uuid(), description.getName(), - description.getFieldIds(), fragmentIds, metric)); + description.getFieldIds(), fragmentIds, segment.indexType(), metric)); } } return result; @@ -166,8 +167,8 @@ private static String parseMetric(String detailsJson) { JsonNode metric = JsonUtil.readTree(detailsJson).get("metric_type"); return metric == null || !metric.isTextual() ? null : metric.asText().toUpperCase(); } catch (RuntimeException e) { - // Index details are optional compatibility metadata. An unknown legacy encoding should - // disable metric-sensitive segment planning rather than prevent ordinary table access. + // Index details are optional metadata. Malformed details disable metric-sensitive + // segment planning rather than preventing ordinary table access. return null; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/IndexSegmentSplitPlan.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/IndexSegmentSplitPlan.java index c8c61fa46a33d1..b890242ddff16c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/IndexSegmentSplitPlan.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/IndexSegmentSplitPlan.java @@ -26,7 +26,7 @@ import java.util.Set; import java.util.UUID; -/** Builds vector-search splits from physical Lance index segments and unindexed fragments. */ +/** Builds external-search splits from physical Lance index segments and optional fragments. */ final class IndexSegmentSplitPlan { private final String datasetUri; private final long version; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java index 394cb6ebf068a1..16a20a6176218a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java @@ -39,10 +39,13 @@ import org.apache.doris.thrift.TExternalSearchRequest; import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFullTextSearchParams; import org.apache.doris.thrift.TLanceFileDesc; import org.apache.doris.thrift.TLanceScanParams; import org.apache.doris.thrift.TTableFormatFileDesc; import org.apache.doris.thrift.TVectorMetric; +import org.apache.doris.thrift.TVectorSearchOptions; import org.apache.doris.thrift.TVectorSearchParams; import java.nio.ByteBuffer; @@ -61,14 +64,22 @@ * Keeping them in one node prevents those common parts from drifting apart. The search request is * also an explicit mode marker. Ordinary scans are split by fragment. Indexed vector searches are * split by physical index segment, with uncovered fragments retained as flat-search fallbacks. - * Each search split produces local candidates; a Doris TopN above this scan merges them into the - * requested snapshot-wide result. + * Full-text searches are split only by committed inverted-index segments, with coverage governed + * by the request's STRICT or INDEX_ONLY mode. Each search split produces local candidates; a Doris + * TopN above this scan merges them into the requested snapshot-wide result. */ public class LanceScanNode extends FileQueryScanNode { + private enum SearchKind { + NORMAL, + VECTOR, + FULL_TEXT + } + private LanceExternalTable lanceTable; private LanceTableMetadata plannedMetadata; - private int vectorFieldId = -1; - private TExternalSearchRequest externalSearchRequest; + private final int searchFieldId; + private final TExternalSearchRequest externalSearchRequest; + private final SearchKind searchKind; private byte[] lanceSubstraitFilter = new byte[0]; private String lancePushdownPredicate = ""; private long plannedVersion = -1; @@ -81,38 +92,45 @@ public LanceScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColum SessionVariable sessionVariable, ScanContext scanContext) { super(id, desc, "LANCE_SCAN_NODE", StatisticalType.LANCE_SCAN_NODE, scanContext, needCheckColumnPriv, sessionVariable); + this.searchFieldId = -1; + this.externalSearchRequest = null; + this.searchKind = SearchKind.NORMAL; } /** * Creates the search mode of this node. * *

The tuple descriptor belongs to a FunctionGenTable and contains generated columns such as - * {@code _distance}. Therefore the real Lance table and the metadata snapshot selected while - * analyzing the TVF must be passed separately. + * {@code _distance} or {@code _score}. Therefore the real Lance table and the metadata + * snapshot selected while analyzing the TVF must be passed separately. */ - public static LanceScanNode forVectorSearch(PlanNodeId id, TupleDescriptor desc, - LanceExternalTable lanceTable, LanceTableMetadata plannedMetadata, int vectorFieldId, + public static LanceScanNode forExternalSearch(PlanNodeId id, TupleDescriptor desc, + LanceExternalTable lanceTable, LanceTableMetadata plannedMetadata, int searchFieldId, TExternalSearchRequest externalSearchRequest, SessionVariable sessionVariable) { - return new LanceScanNode(id, desc, lanceTable, plannedMetadata, vectorFieldId, + return new LanceScanNode(id, desc, lanceTable, plannedMetadata, searchFieldId, externalSearchRequest, sessionVariable); } private LanceScanNode(PlanNodeId id, TupleDescriptor desc, LanceExternalTable lanceTable, - LanceTableMetadata plannedMetadata, int vectorFieldId, + LanceTableMetadata plannedMetadata, int searchFieldId, TExternalSearchRequest externalSearchRequest, SessionVariable sessionVariable) { super(id, desc, "LANCE_SCAN_NODE", StatisticalType.LANCE_SCAN_NODE, ScanContext.builder().clusterName(sessionVariable.resolveCloudClusterName()).build(), false, sessionVariable); this.lanceTable = lanceTable; this.plannedMetadata = plannedMetadata; - this.vectorFieldId = vectorFieldId; + this.searchFieldId = searchFieldId; + if (externalSearchRequest == null) { + throw new IllegalArgumentException("Lance external search request must not be null"); + } this.externalSearchRequest = externalSearchRequest.deepCopy(); + this.searchKind = resolveSearchKind(this.externalSearchRequest); } @Override protected void doInitialize() throws UserException { List sourceColumns; - if (isExternalSearch()) { + if (searchKind != SearchKind.NORMAL) { sourceColumns = desc.getTable().getColumns(); } else { lanceTable = (LanceExternalTable) desc.getTable(); @@ -123,11 +141,11 @@ protected void doInitialize() throws UserException { super.doInitialize(); ExternalUtil.initSchemaInfo(params, -1L, sourceColumns); - if (isExternalSearch()) { + if (searchKind != SearchKind.NORMAL) { // Search output comes from the FunctionGenTable because it adds generated columns such - // as _distance. The real Lance table is still retained for storage and metadata access. + // as _distance or _score. The real Lance table is retained for storage and metadata. getOrCreateLanceScanParams() - .setExternalSearchRequest(createFragmentSearchRequest(externalSearchRequest)); + .setExternalSearchRequest(createSplitSearchRequest()); } } @@ -153,9 +171,9 @@ private boolean canPushDownLimit() { @Override protected void convertPredicate() { - if (isExternalSearch()) { + if (searchKind != SearchKind.NORMAL) { // The TVF "filter" property is already serialized in externalSearchRequest and is - // evaluated by Lance before vector search. Outer WHERE conjuncts have different + // evaluated by Lance before candidate search. Outer WHERE conjuncts have different // semantics: keep them as Doris scan residuals. Each fragment first returns its Lance // ANN candidates, then Doris evaluates these conjuncts before the local/global TopN. } else { @@ -186,20 +204,31 @@ public List getSplits(int numBackends) throws UserException { LanceTableMetadata metadata = plannedMetadata; plannedVersion = metadata.getVersion(); plannedFragments = metadata.getFragments().size(); - plannedUnindexedFragments = isExternalSearch() ? plannedFragments : 0; + plannedUnindexedFragments = searchKind == SearchKind.NORMAL ? 0 : plannedFragments; plannedIndexSegments = 0; plannedIndexFragments = 0; - if (isExternalSearch() && plannedVersion <= 0) { + if (searchKind != SearchKind.NORMAL && plannedVersion <= 0) { throw new UserException( - "Lance vector search requires a fixed positive dataset version"); + "Lance external search requires a fixed positive dataset version"); } Map visibleFragments = getVisibleFragments(metadata); - if (isExternalSearch() && shouldUseIndex()) { - Optional> indexSplits = createIndexSegmentSplits(metadata, visibleFragments); - if (indexSplits.isPresent()) { - return indexSplits.get(); - } + switch (searchKind) { + case FULL_TEXT: + return createFullTextIndexSegmentSplits(metadata, visibleFragments); + case VECTOR: + if (isVectorIndexEnabled()) { + Optional> indexSplits = createVectorIndexSegmentSplits( + metadata, visibleFragments); + if (indexSplits.isPresent()) { + return indexSplits.get(); + } + } + break; + case NORMAL: + break; + default: + throw new IllegalStateException("Unsupported Lance search kind " + searchKind); } return createFragmentSplits(metadata, visibleFragments); } @@ -236,25 +265,25 @@ private List createFragmentSplits(LanceTableMetadata metadata, return splits; } - private Optional> createIndexSegmentSplits(LanceTableMetadata metadata, + private Optional> createVectorIndexSegmentSplits(LanceTableMetadata metadata, Map visibleFragments) throws UserException { if (metadata.getIndexSegments().isEmpty()) { return Optional.empty(); } TVectorSearchParams vectorSearchParam = externalSearchRequest.getSearchQuery().getVectorSearch(); - if (vectorFieldId < 0) { + if (searchFieldId < 0) { throw new UserException("Lance vector column '" + vectorSearchParam.getColumn() + "' has no field ID in the Lance schema"); } - List matchingSegments = selectIndexSegments( - metadata.getIndexSegments(), vectorFieldId); + List matchingSegments = selectVectorIndexSegments( + metadata.getIndexSegments(), searchFieldId); if (matchingSegments.isEmpty() || !metricMatches(vectorSearchParam, matchingSegments)) { return Optional.empty(); } Optional indexPlan = planIndexSegments( - metadata, matchingSegments, visibleFragments); + metadata, matchingSegments, visibleFragments, false); if (!indexPlan.isPresent()) { return Optional.empty(); } @@ -266,12 +295,44 @@ private Optional> createIndexSegmentSplits(LanceTableMetadata metada return Optional.of(plan.buildSplits()); } - private static List selectIndexSegments( - List indexSegments, int vectorFieldId) { + private List createFullTextIndexSegmentSplits(LanceTableMetadata metadata, + Map visibleFragments) throws UserException { + TFullTextSearchParams fullText = + externalSearchRequest.getSearchQuery().getFullTextSearch(); + if (searchFieldId < 0) { + throw new UserException("Lance full-text column '" + fullText.getColumn() + + "' has no field ID in the Lance schema"); + } + List matchingSegments = selectFullTextIndexSegments( + metadata.getIndexSegments(), searchFieldId, fullText.getColumn()); + if (matchingSegments.isEmpty()) { + throw new UserException("No committed Lance FTS index exists for column '" + + fullText.getColumn() + "' at dataset version " + metadata.getVersion()); + } + IndexSegmentSplitPlan plan = planIndexSegments( + metadata, matchingSegments, visibleFragments, true) + .orElseThrow(() -> new UserException("Lance FTS index for column '" + + fullText.getColumn() + "' has no visible indexed fragments at dataset version " + + metadata.getVersion())); + plannedIndexSegments = plan.splitCount(); + plannedIndexFragments = plan.indexSegmentFragmentCount(); + plannedUnindexedFragments = plannedFragments - plannedIndexFragments; + if (fullText.getCoverageMode() == TFtsCoverageMode.STRICT + && plannedUnindexedFragments != 0) { + throw new UserException("Lance FTS coverage_mode=STRICT requires every fragment at " + + "dataset version " + metadata.getVersion() + " to be indexed; column '" + + fullText.getColumn() + "' has " + plannedUnindexedFragments + + " unindexed fragments. Rebuild the index or use coverage_mode=index_only"); + } + return plan.buildSplits(); + } + + private static List selectVectorIndexSegments( + List indexSegments, int fieldId) { List selectedSegments = new ArrayList<>(); String selectedIndexName = null; for (LanceIndexSegmentInfo segment : indexSegments) { - if (!segment.getFieldIds().contains(vectorFieldId)) { + if (!segment.isVectorIndex() || !segment.getFieldIds().contains(fieldId)) { continue; } if (selectedIndexName == null) { @@ -284,19 +345,52 @@ private static List selectIndexSegments( return selectedSegments; } + private static List selectFullTextIndexSegments( + List indexSegments, int fieldId, String column) + throws UserException { + List selectedSegments = new ArrayList<>(); + String selectedIndexName = null; + for (LanceIndexSegmentInfo segment : indexSegments) { + if (!segment.isFullTextIndex() || !segment.getFieldIds().contains(fieldId)) { + continue; + } + if (selectedIndexName == null) { + selectedIndexName = segment.getIndexName(); + } else if (!selectedIndexName.equals(segment.getIndexName())) { + throw new UserException("Multiple Lance FTS indexes exist for column '" + column + + "'; distributed FTS requires one unambiguous logical index"); + } + selectedSegments.add(segment); + } + return selectedSegments; + } + private static Optional planIndexSegments( LanceTableMetadata metadata, List indexSegments, - Map visibleFragments) { + Map visibleFragments, + boolean requireKnownCoverage) throws UserException { IndexSegmentSplitPlan plan = new IndexSegmentSplitPlan( metadata.getDatasetUri(), metadata.getVersion(), indexSegments.size()); for (LanceIndexSegmentInfo segment : indexSegments) { Optional> segmentFragments = segment.getFragmentIds(); if (!segmentFragments.isPresent()) { + if (requireKnownCoverage) { + throw new UserException("Lance FTS segment " + segment.getUuid() + + " has no fragment coverage metadata"); + } return Optional.empty(); } List visibleIndexSegmentFragmentIds = effectiveFragmentIds( segmentFragments.get(), visibleFragments); + if (requireKnownCoverage) { + for (Long fragmentId : visibleIndexSegmentFragmentIds) { + if (plan.isCoveredByIndexSegment(fragmentId)) { + throw new UserException("Lance FTS fragment " + fragmentId + + " is covered by multiple physical index segments"); + } + } + } if (!visibleIndexSegmentFragmentIds.isEmpty()) { plan.addIndexSegmentSplit( segment.getUuid(), visibleIndexSegmentFragmentIds, @@ -339,10 +433,13 @@ private static void appendUnindexedFragmentSplits(IndexSegmentSplitPlan plan, } } - private boolean shouldUseIndex() { - return !externalSearchRequest.isSetVectorSearchOptions() - || !externalSearchRequest.getVectorSearchOptions().isSetUseIndex() - || externalSearchRequest.getVectorSearchOptions().isUseIndex(); + private boolean isVectorIndexEnabled() { + // default use_index is true + if (!externalSearchRequest.isSetVectorSearchOptions()) { + return true; + } + TVectorSearchOptions options = externalSearchRequest.getVectorSearchOptions(); + return !options.isSetUseIndex() || options.isUseIndex(); } private static boolean metricMatches(TVectorSearchParams vector, @@ -372,11 +469,15 @@ protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { if (lanceSplit.getFragmentIds().isEmpty()) { throw new IllegalArgumentException("Lance scan split must contain fragments"); } - if (!isExternalSearch() && (lanceSplit.getFragmentIds().size() != 1 + if (searchKind == SearchKind.NORMAL && (lanceSplit.getFragmentIds().size() != 1 || lanceSplit.hasIndexSegmentUuids())) { throw new IllegalArgumentException( "Ordinary Lance scan split must contain one fragment and no index segment"); } + if (searchKind == SearchKind.FULL_TEXT && !lanceSplit.hasIndexSegmentUuids()) { + throw new IllegalArgumentException( + "Lance full-text search split must contain an FTS index segment"); + } lanceParams.setFragmentIds(lanceSplit.getFragmentIds()); if (lanceSplit.hasIndexSegmentUuids()) { List uuids = new ArrayList<>(lanceSplit.getIndexSegmentUuids().size()); @@ -390,8 +491,8 @@ protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { lanceParams.setIndexSegmentUuids(uuids); } // Push LIMIT into each ordinary fragment scanner only when it is safe to truncate that - // fragment early. Vector search uses its own per-split candidate bound. - if (!isExternalSearch() && canPushDownLimit()) { + // fragment early. External searches use their own per-split candidate bound. + if (searchKind == SearchKind.NORMAL && canPushDownLimit()) { lanceParams.setLimit(getLimit()); } @@ -413,7 +514,7 @@ protected List getPathPartitionKeys() { @Override protected TableIf getTargetTable() { - if (isExternalSearch()) { + if (searchKind != SearchKind.NORMAL) { // In search mode desc.getTable() is a FunctionGenTable, but default-value expressions // and storage access still belong to the underlying Lance table. return lanceTable; @@ -432,13 +533,28 @@ protected Map getLocationProperties() { @Override public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { StringBuilder result = new StringBuilder(super.getNodeExplainString(prefix, detailLevel)); - if (isExternalSearch()) { - TVectorSearchParams vector = externalSearchRequest.getSearchQuery().getVectorSearch(); - result.append(prefix).append("externalSearchType=VECTOR\n"); - result.append(prefix).append("lanceVectorColumn=").append(vector.getColumn()).append("\n"); - result.append(prefix).append("lanceMetric=") - .append(vector.isSetMetric() ? metricName(vector.getMetric()) : "default") - .append("\n"); + if (searchKind != SearchKind.NORMAL) { + if (searchKind == SearchKind.VECTOR) { + TVectorSearchParams vector = + externalSearchRequest.getSearchQuery().getVectorSearch(); + result.append(prefix).append("externalSearchType=VECTOR\n"); + result.append(prefix).append("lanceVectorColumn=") + .append(vector.getColumn()).append("\n"); + result.append(prefix).append("lanceMetric=") + .append(vector.isSetMetric() ? metricName(vector.getMetric()) : "default") + .append("\n"); + } else { + if (searchKind != SearchKind.FULL_TEXT) { + throw new IllegalStateException("Unsupported Lance search kind " + searchKind); + } + TFullTextSearchParams fullText = + externalSearchRequest.getSearchQuery().getFullTextSearch(); + result.append(prefix).append("externalSearchType=FULL_TEXT\n"); + result.append(prefix).append("lanceFullTextColumn=") + .append(fullText.getColumn()).append("\n"); + result.append(prefix).append("lanceFtsCoverageMode=") + .append(fullText.getCoverageMode()).append("\n"); + } result.append(prefix).append("lanceVersion=") .append(plannedMetadata.getVersion()).append("\n"); result.append(prefix).append("lanceSearchFragments=") @@ -465,19 +581,40 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { return result.toString(); } - private boolean isExternalSearch() { - return externalSearchRequest != null; + TExternalSearchRequest createSplitSearchRequest() { + TExternalSearchRequest splitRequest = externalSearchRequest.deepCopy(); + // Every split must retain enough rows for the later global OFFSET/LIMIT. Applying the + // logical offset independently inside each split could discard rows that belong to the + // snapshot-wide result. + switch (searchKind) { + case VECTOR: + TVectorSearchParams vector = splitRequest.getSearchQuery().getVectorSearch(); + vector.setTopK(vector.getTopK() + vector.getOffset()); + vector.setOffset(0); + break; + case FULL_TEXT: + TFullTextSearchParams fullText = splitRequest.getSearchQuery().getFullTextSearch(); + fullText.setTopK(fullText.getTopK() + fullText.getOffset()); + fullText.setOffset(0); + break; + case NORMAL: + default: + throw new IllegalStateException("Cannot create a search split for " + searchKind); + } + return splitRequest; } - static TExternalSearchRequest createFragmentSearchRequest(TExternalSearchRequest searchRequest) { - TExternalSearchRequest fragmentRequest = searchRequest.deepCopy(); - TVectorSearchParams vector = fragmentRequest.getSearchQuery().getVectorSearch(); - // Every fragment must retain enough rows for the later global OFFSET/LIMIT. Applying the - // logical offset independently inside each fragment could discard rows that belong to the - // snapshot-wide result. - vector.setTopK(vector.getTopK() + vector.getOffset()); - vector.setOffset(0); - return fragmentRequest; + private static SearchKind resolveSearchKind(TExternalSearchRequest searchRequest) { + if (!searchRequest.isSetSearchQuery()) { + throw new IllegalArgumentException("Lance external search request requires search_query"); + } + boolean hasVector = searchRequest.getSearchQuery().isSetVectorSearch(); + boolean hasFullText = searchRequest.getSearchQuery().isSetFullTextSearch(); + if (hasVector == hasFullText) { + throw new IllegalArgumentException( + "Lance external search query must set exactly one search kind"); + } + return hasVector ? SearchKind.VECTOR : SearchKind.FULL_TEXT; } private static String metricName(TVectorMetric metric) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java index feed5e456ac663..702bc48614712f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/materialize/MaterializeProbeVisitor.java @@ -38,6 +38,7 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalTVFRelation; import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanVisitor; import org.apache.doris.qe.SessionVariable; +import org.apache.doris.tablefunction.FullTextSearchTableValuedFunction; import org.apache.doris.tablefunction.VectorSearchTableValuedFunction; import com.google.common.collect.ImmutableSet; @@ -129,7 +130,7 @@ boolean checkRelationTableSupportedType(PhysicalCatalogRelation relation) { } boolean checkTVFRelationTableSupportedType(PhysicalTVFRelation tvfRelation) { - if (isVectorSearch(tvfRelation)) { + if (isLanceExternalSearch(tvfRelation)) { return true; } @@ -146,8 +147,10 @@ boolean checkTVFRelationTableSupportedType(PhysicalTVFRelation tvfRelation) { return false; } - private boolean isVectorSearch(PhysicalTVFRelation tvfRelation) { - return VectorSearchTableValuedFunction.NAME.equals(tvfRelation.getFunction().getName()); + private boolean isLanceExternalSearch(PhysicalTVFRelation tvfRelation) { + String functionName = tvfRelation.getFunction().getName(); + return VectorSearchTableValuedFunction.NAME.equals(functionName) + || FullTextSearchTableValuedFunction.NAME.equals(functionName); } @Override @@ -189,7 +192,7 @@ public Optional visitPhysicalTVFRelation( PhysicalTVFRelation tvfRelation, ProbeContext context) { // The first Lance implementation fetches top-level columns by row ID. Keep nested // sub-column projections in the search phase until take_rows supports access paths. - if (isVectorSearch(tvfRelation) && context.slot.hasSubColPath()) { + if (isLanceExternalSearch(tvfRelation) && context.slot.hasSubColPath()) { return Optional.empty(); } if (checkTVFRelationTableSupportedType(tvfRelation) && tvfRelation.getOutput().contains(context.slot) diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java index 41cce5539a30d3..d145e09c875e4e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindExpression.java @@ -68,6 +68,7 @@ import org.apache.doris.nereids.trees.expressions.functions.generator.Unnest; import org.apache.doris.nereids.trees.expressions.functions.scalar.ElementAt; import org.apache.doris.nereids.trees.expressions.functions.scalar.GroupingScalarFunction; +import org.apache.doris.nereids.trees.expressions.functions.table.FullTextSearch; import org.apache.doris.nereids.trees.expressions.functions.table.TableValuedFunction; import org.apache.doris.nereids.trees.expressions.functions.table.VectorSearch; import org.apache.doris.nereids.trees.expressions.literal.IntegerLikeLiteral; @@ -116,6 +117,7 @@ import org.apache.doris.nereids.util.TypeCoercionUtils; import org.apache.doris.nereids.util.Utils; import org.apache.doris.qe.SqlModeHelper; +import org.apache.doris.tablefunction.FullTextSearchTableValuedFunction; import org.apache.doris.tablefunction.VectorSearchTableValuedFunction; import com.google.common.base.Joiner; @@ -1788,7 +1790,8 @@ private Plan bindTableValuedFunction(MatchingContext ctx) { TableValuedFunction tableValuedFunction = (TableValuedFunction) bindResult.first; LogicalTVFRelation relation = new LogicalTVFRelation( unboundTVFRelation.getRelationId(), tableValuedFunction, ImmutableList.of()); - if (!(tableValuedFunction instanceof VectorSearch)) { + if (!(tableValuedFunction instanceof VectorSearch) + && !(tableValuedFunction instanceof FullTextSearch)) { return relation; } @@ -1796,17 +1799,31 @@ private Plan bindTableValuedFunction(MatchingContext ctx) { // relation with a Doris TopN to merge them into the snapshot-wide result. The predicate // pushdown rules move an outer WHERE below this synthetic TopN, where it is evaluated as // a Doris scan residual after each fragment's Lance search and before the global TopN. - VectorSearchTableValuedFunction vectorSearch = - (VectorSearchTableValuedFunction) tableValuedFunction.getCatalogFunction(); - Slot distance = relation.getOutput().stream() - .filter(slot -> slot.getName().equalsIgnoreCase( - VectorSearchTableValuedFunction.DISTANCE_COLUMN)) + if (tableValuedFunction instanceof VectorSearch) { + VectorSearchTableValuedFunction vectorSearch = + (VectorSearchTableValuedFunction) tableValuedFunction.getCatalogFunction(); + Slot distance = requireSearchResultSlot( + relation, VectorSearchTableValuedFunction.DISTANCE_COLUMN, + VectorSearchTableValuedFunction.NAME); + return new LogicalTopN<>(ImmutableList.of(new OrderKey(distance, true, false)), + vectorSearch.getTopK(), vectorSearch.getOffset(), relation); + } + FullTextSearchTableValuedFunction fullTextSearch = + (FullTextSearchTableValuedFunction) tableValuedFunction.getCatalogFunction(); + Slot score = requireSearchResultSlot( + relation, FullTextSearchTableValuedFunction.SCORE_COLUMN, + FullTextSearchTableValuedFunction.NAME); + return new LogicalTopN<>(ImmutableList.of(new OrderKey(score, false, false)), + fullTextSearch.getTopK(), fullTextSearch.getOffset(), relation); + } + + private Slot requireSearchResultSlot( + LogicalTVFRelation relation, String column, String functionName) { + return relation.getOutput().stream() + .filter(slot -> slot.getName().equalsIgnoreCase(column)) .findFirst() - .orElseThrow(() -> new AnalysisException("vector_search() output is missing '" - + VectorSearchTableValuedFunction.DISTANCE_COLUMN + "'")); - OrderKey distanceAscending = new OrderKey(distance, true, false); - return new LogicalTopN<>(ImmutableList.of(distanceAscending), - vectorSearch.getTopK(), vectorSearch.getOffset(), relation); + .orElseThrow(() -> new AnalysisException(functionName + "() output is missing '" + + column + "'")); } private void checkIfOutputAliasNameDuplicatedForGroupBy(Collection expressions, diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownFilterThroughVectorSearchTopN.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownFilterThroughVectorSearchTopN.java index f6c7feed6dbb7a..73f65e5a5ed8be 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownFilterThroughVectorSearchTopN.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownFilterThroughVectorSearchTopN.java @@ -23,24 +23,25 @@ import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; import org.apache.doris.nereids.trees.plans.logical.LogicalTVFRelation; import org.apache.doris.nereids.trees.plans.logical.LogicalTopN; +import org.apache.doris.tablefunction.FullTextSearchTableValuedFunction; import org.apache.doris.tablefunction.VectorSearchTableValuedFunction; /** - * Move an outer vector_search WHERE predicate below its Doris merge TopN. + * Move an outer Lance external-search WHERE predicate below its Doris merge TopN. * - *

The TopN immediately above a vector_search TVF is added by {@code BindExpression} to merge + *

The TopN immediately above a search TVF is added by {@code BindExpression} to merge * the candidates returned by all Lance fragment scans. The SQL WHERE predicate must therefore be * evaluated below this TopN so it can become a residual conjunct on the Doris Lance scan node: * *

  * Filter                         TopN
  *   TopN            ->            Filter
- *     vector_search                 vector_search
+ *     search TVF                    search TVF
  * 
* - *

This remains a postfilter relative to Lance nearest(): every fragment first returns its ANN - * candidates, and Doris filters those candidates before the local/global TopN. It is deliberately - * not converted into the Lance prefilter carried by the TVF's {@code filter} property. + *

This remains a postfilter relative to the Lance search: every split first returns candidates, + * and Doris filters those candidates before the local/global TopN. It is deliberately not + * converted into the Lance prefilter carried by the TVF's {@code filter} property. */ public class PushDownFilterThroughVectorSearchTopN extends OneRewriteRuleFactory { @Override @@ -48,8 +49,9 @@ public Rule build() { return logicalFilter(logicalTopN(logicalTVFRelation())) .then(filter -> { LogicalTopN topN = filter.child(); - if (!VectorSearchTableValuedFunction.NAME.equals( - topN.child().getFunction().getName())) { + String functionName = topN.child().getFunction().getName(); + if (!VectorSearchTableValuedFunction.NAME.equals(functionName) + && !FullTextSearchTableValuedFunction.NAME.equals(functionName)) { return null; } LogicalFilter scanFilter = new LogicalFilter<>( diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/FullTextSearch.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/FullTextSearch.java new file mode 100644 index 00000000000000..55b297ae6688c7 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/table/FullTextSearch.java @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.trees.expressions.functions.table; + +import org.apache.doris.catalog.FunctionSignature; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.Properties; +import org.apache.doris.nereids.types.coercion.AnyDataType; +import org.apache.doris.tablefunction.FullTextSearchTableValuedFunction; +import org.apache.doris.tablefunction.TableValuedFunctionIf; + +import java.util.Map; + +/** Lance full_text_search relation TVF. */ +public class FullTextSearch extends TableValuedFunction { + public FullTextSearch(Properties properties) { + super(FullTextSearchTableValuedFunction.NAME, properties); + } + + @Override + public FunctionSignature customSignature() { + return FunctionSignature.of(AnyDataType.INSTANCE_WITHOUT_INDEX, getArgumentsTypes()); + } + + @Override + protected TableValuedFunctionIf toCatalogFunction() { + try { + Map arguments = getTVFProperties().getMap(); + return new FullTextSearchTableValuedFunction(arguments); + } catch (Throwable t) { + throw new AnalysisException("Can not build full_text_search(): " + t.getMessage(), t); + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java new file mode 100644 index 00000000000000..735b65d366fdf5 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java @@ -0,0 +1,113 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.tablefunction; + +import org.apache.doris.common.AnalysisException; +import org.apache.doris.datasource.lance.LanceTableMetadata; +import org.apache.doris.thrift.TExternalSearchQuery; +import org.apache.doris.thrift.TExternalSearchRequest; +import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFullTextSearchParams; + +import com.google.common.collect.ImmutableSet; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; + +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** Relation TVF for a fixed-snapshot distributed Lance full-text search. */ +public class FullTextSearchTableValuedFunction extends LanceExternalSearchTableValuedFunction { + public static final String NAME = "full_text_search"; + public static final String SCORE_COLUMN = "_score"; + + private static final String QUERY = "query"; + private static final String COVERAGE_MODE = "coverage_mode"; + private static final Set PROPERTIES = ImmutableSet.of( + TABLE, COLUMN, QUERY, TOP_K, OFFSET, FILTER, COVERAGE_MODE); + + public FullTextSearchTableValuedFunction(Map properties) + throws AnalysisException { + super(prepare(properties)); + } + + private static PreparedSearch prepare(Map properties) + throws AnalysisException { + Map params = normalizeProperties(properties, PROPERTIES, NAME); + CommonSearch common = prepareCommon(params, NAME, + "FullTextSearchTableValuedFunction", "full-text search", true); + + Field field = findStringField(common.metadata(), required(params, COLUMN, NAME)); + int fieldId = requireLanceFieldId(common.metadata(), field, "full-text"); + String query = required(params, QUERY, NAME); + if (query.indexOf('\0') >= 0) { + throw new AnalysisException("'query' must not contain an embedded NUL byte"); + } + + TFullTextSearchParams fullTextParams = new TFullTextSearchParams() + .setColumn(field.getName()) + .setQuery(query) + .setTopK(common.topK()) + .setOffset(common.offset()) + .setCoverageMode(parseCoverageMode( + params.getOrDefault(COVERAGE_MODE, "strict"))); + TExternalSearchRequest searchRequest = new TExternalSearchRequest() + .setSchemaVersion(1) + .setSearchQuery(TExternalSearchQuery.full_text_search(fullTextParams)); + return prepareSearch( + common, fieldId, searchRequest, SCORE_COLUMN, "full-text search"); + } + + private static Field findStringField(LanceTableMetadata metadata, String column) + throws AnalysisException { + Field match = null; + for (Field field : metadata.getSchema().getFields()) { + if (field.getName().equalsIgnoreCase(column)) { + if (match != null) { + throw new AnalysisException("Lance full-text column '" + column + + "' is ambiguous under case-insensitive matching"); + } + match = field; + } + } + if (match == null) { + throw new AnalysisException("Lance full-text column '" + column + "' does not exist"); + } + ArrowType.ArrowTypeID typeId = match.getType().getTypeID(); + if (typeId != ArrowType.ArrowTypeID.Utf8 + && typeId != ArrowType.ArrowTypeID.LargeUtf8) { + throw new AnalysisException("Lance full-text column '" + match.getName() + + "' must be STRING"); + } + return match; + } + + private static TFtsCoverageMode parseCoverageMode(String value) throws AnalysisException { + switch (value.trim().toLowerCase(Locale.ROOT)) { + case "strict": + return TFtsCoverageMode.STRICT; + case "index_only": + case "index-only": + return TFtsCoverageMode.INDEX_ONLY; + default: + throw new AnalysisException("Unsupported FTS coverage_mode '" + value + + "': expected strict or index_only"); + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LanceExternalSearchTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LanceExternalSearchTableValuedFunction.java new file mode 100644 index 00000000000000..856ee108fc6c9a --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/LanceExternalSearchTableValuedFunction.java @@ -0,0 +1,360 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.tablefunction; + +import org.apache.doris.analysis.TableName; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.TableIf; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.common.ErrorCode; +import org.apache.doris.common.ErrorReport; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.lance.LanceExternalCatalog; +import org.apache.doris.datasource.lance.LanceExternalTable; +import org.apache.doris.datasource.lance.LanceTableMetadata; +import org.apache.doris.datasource.lance.LanceTypeConverter; +import org.apache.doris.datasource.lance.source.LanceScanNode; +import org.apache.doris.mysql.privilege.PrivPredicate; +import org.apache.doris.nereids.analyzer.UnboundSlot; +import org.apache.doris.nereids.exceptions.ParseException; +import org.apache.doris.nereids.parser.NereidsParser; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.planner.PlanNodeId; +import org.apache.doris.planner.ScanNode; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; +import org.apache.doris.thrift.TExternalSearchRequest; +import org.apache.doris.thrift.TSearchFilter; +import org.apache.doris.thrift.TSearchFilterFormat; + +import org.apache.arrow.vector.types.pojo.Field; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalInt; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** Common immutable planning state and validation for Lance external-search relation TVFs. */ +abstract class LanceExternalSearchTableValuedFunction extends TableValuedFunctionIf { + protected static final String TABLE = "table"; + protected static final String COLUMN = "column"; + protected static final String TOP_K = "top_k"; + protected static final String OFFSET = "offset"; + protected static final String FILTER = "filter"; + + private static final String FULLY_QUALIFIED_TABLE_NAME_ERROR = + "'table' must be a fully qualified catalog.database.table name"; + private static final long UINT32_MAX = 0xFFFF_FFFFL; + + private final String displayName; + private final TableName sourceTableName; + private final LanceExternalTable sourceTable; + private final LanceTableMetadata metadata; + private final int fieldId; + private final TExternalSearchRequest searchRequest; + private final List columns; + private final long topK; + private final long offset; + + protected LanceExternalSearchTableValuedFunction(PreparedSearch prepared) { + CommonSearch common = prepared.common; + this.displayName = common.displayName; + this.sourceTableName = common.sourceTableName; + this.sourceTable = common.sourceTable; + this.metadata = common.metadata; + this.fieldId = prepared.fieldId; + this.searchRequest = prepared.searchRequest.deepCopy(); + this.columns = Collections.unmodifiableList(new ArrayList<>(prepared.columns)); + this.topK = common.topK; + this.offset = common.offset; + } + + public final LanceExternalTable getSourceTable() { + return sourceTable; + } + + public final LanceTableMetadata getMetadata() { + return metadata; + } + + public final TExternalSearchRequest getSearchRequest() { + return searchRequest.deepCopy(); + } + + public final long getTopK() { + return topK; + } + + public final long getOffset() { + return offset; + } + + @Override + public final String getTableName() { + return displayName + "<" + sourceTableName + ">"; + } + + @Override + public final List getTableColumns() { + return columns; + } + + @Override + public final ScanNode getScanNode(PlanNodeId id, TupleDescriptor desc, SessionVariable sv) { + return LanceScanNode.forExternalSearch( + id, desc, sourceTable, metadata, fieldId, searchRequest, sv); + } + + protected static Map normalizeProperties(Map properties, + Set allowedProperties, String functionName) throws AnalysisException { + Map normalized = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry entry : properties.entrySet()) { + String key = entry.getKey().toLowerCase(Locale.ROOT); + if (!allowedProperties.contains(key)) { + throw new AnalysisException("'" + entry.getKey() + + "' is an invalid property for " + functionName + "()"); + } + if (normalized.put(key, entry.getValue()) != null) { + throw new AnalysisException( + "Duplicate " + functionName + "() property '" + key + "'"); + } + } + return normalized; + } + + protected static String required(Map params, String key, String functionName) + throws AnalysisException { + String value = params.get(key); + if (value == null || value.trim().isEmpty()) { + throw new AnalysisException( + "Missing required " + functionName + "() property '" + key + "'"); + } + return value.trim(); + } + + protected static CommonSearch prepareCommon(Map params, String functionName, + String displayName, String searchDescription, boolean loadIndexMetadata) + throws AnalysisException { + TableName sourceTableName = parseTableName(required(params, TABLE, functionName)); + LanceExternalTable sourceTable = findLanceExternalTable(sourceTableName); + LanceTableMetadata metadata; + try { + metadata = loadIndexMetadata + ? sourceTable.loadMetadataForSearch() : sourceTable.loadMetadata(); + } catch (RuntimeException e) { + throw new AnalysisException("Failed to load Lance metadata for " + searchDescription + + " on " + sourceTableName + ": " + e.getMessage(), e); + } + if (metadata.getVersion() <= 0) { + throw new AnalysisException("Lance " + searchDescription + + " requires a fixed positive dataset version"); + } + + long topK = parseLong(params.getOrDefault(TOP_K, "10"), TOP_K, 1, Long.MAX_VALUE); + long offset = parseLong(params.getOrDefault(OFFSET, "0"), OFFSET, 0, Long.MAX_VALUE); + if (offset > UINT32_MAX || topK > UINT32_MAX - offset) { + throw new AnalysisException("'top_k + offset' must not exceed " + UINT32_MAX); + } + return new CommonSearch(params, displayName, sourceTableName, sourceTable, metadata, + topK, offset); + } + + protected static PreparedSearch prepareSearch(CommonSearch common, int fieldId, + TExternalSearchRequest searchRequest, String resultColumn, String searchDescription) + throws AnalysisException { + if (common.params.containsKey(FILTER)) { + searchRequest.setSearchFilter(new TSearchFilter() + .setFormat(TSearchFilterFormat.SQL) + .setPayload(validateAndEncodeSqlFilter(common.params.get(FILTER)))); + } + List columns = buildOutputColumns( + common.metadata, resultColumn, searchDescription); + return new PreparedSearch(common, fieldId, searchRequest, columns); + } + + protected static int requireLanceFieldId(LanceTableMetadata metadata, Field field, + String searchDescription) throws AnalysisException { + OptionalInt fieldId = metadata.getLanceFieldId(field.getName()); + if (!fieldId.isPresent()) { + throw new AnalysisException("Lance " + searchDescription + " column '" + + field.getName() + "' has no field ID in the Lance schema"); + } + return fieldId.getAsInt(); + } + + protected static List buildOutputColumns(LanceTableMetadata metadata, + String resultColumn, String searchDescription) throws AnalysisException { + List result = new ArrayList<>(metadata.getSchema().getFields().size() + 1); + Set fieldNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + int position = 0; + for (Field field : metadata.getSchema().getFields()) { + if (!fieldNames.add(field.getName())) { + throw new AnalysisException("Duplicate Lance schema column under " + + "case-insensitive matching: '" + field.getName() + "'"); + } + if (field.getName().startsWith(Column.GLOBAL_ROWID_COL)) { + throw new AnalysisException("Lance table contains column '" + field.getName() + + "' using reserved Doris internal column prefix '" + + Column.GLOBAL_ROWID_COL + "'"); + } + if (field.getName().equalsIgnoreCase(resultColumn)) { + throw new AnalysisException("Lance table already contains reserved " + + searchDescription + " column '" + resultColumn + "'"); + } + String comment = field.getMetadata() == null + ? null : field.getMetadata().get("comment"); + Type type; + try { + type = LanceTypeConverter.toDorisType(field); + } catch (RuntimeException e) { + throw new AnalysisException("Invalid Lance type for column '" + field.getName() + + "': " + e.getMessage(), e); + } + result.add(new Column(field.getName(), type, false, null, + field.isNullable(), comment, true, position++)); + } + result.add(new Column(resultColumn, Type.FLOAT, false, null, + true, null, true, position)); + return result; + } + + protected static TableName parseTableName(String value) throws AnalysisException { + Expression expression; + try { + expression = new NereidsParser().parseExpression(value); + } catch (ParseException e) { + throw new AnalysisException(FULLY_QUALIFIED_TABLE_NAME_ERROR, e); + } + if (!(expression instanceof UnboundSlot)) { + throw new AnalysisException(FULLY_QUALIFIED_TABLE_NAME_ERROR); + } + List names = ((UnboundSlot) expression).getNameParts(); + if (names.size() != 3) { + throw new AnalysisException(FULLY_QUALIFIED_TABLE_NAME_ERROR); + } + return new TableName(names.get(0), names.get(1), names.get(2)); + } + + protected static LanceExternalTable findLanceExternalTable(TableName tableName) + throws AnalysisException { + ConnectContext context = ConnectContext.get(); + if (!Env.getCurrentEnv().getAccessManager() + .checkTblPriv(context, tableName, PrivPredicate.SELECT)) { + ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, "SELECT", + context.getQualifiedUser(), context.getRemoteIP(), + tableName.getDb() + ": " + tableName.getTbl()); + } + CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(tableName.getCtl()); + if (!(catalog instanceof LanceExternalCatalog)) { + throw new AnalysisException("Catalog '" + tableName.getCtl() + + "' is not a Lance catalog"); + } + TableIf table = catalog.getDbOrAnalysisException(tableName.getDb()) + .getTableOrAnalysisException(tableName.getTbl()); + if (!(table instanceof LanceExternalTable)) { + throw new AnalysisException("Table '" + tableName + "' is not a Lance table"); + } + return (LanceExternalTable) table; + } + + protected static byte[] validateAndEncodeSqlFilter(String filter) throws AnalysisException { + if (filter == null || filter.trim().isEmpty()) { + throw new AnalysisException("'filter' must not be empty"); + } + if (filter.indexOf('\0') >= 0) { + throw new AnalysisException("'filter' must not contain an embedded NUL byte"); + } + return filter.getBytes(StandardCharsets.UTF_8); + } + + protected static long parseLong(String value, String property, long min, long max) + throws AnalysisException { + try { + long parsed = Long.parseLong(value); + if (parsed < min || parsed > max) { + throw new AnalysisException("'" + property + "' must be between " + + min + " and " + max); + } + return parsed; + } catch (NumberFormatException e) { + throw new AnalysisException("'" + property + "' must be an integer", e); + } + } + + protected static final class CommonSearch { + private final Map params; + private final String displayName; + private final TableName sourceTableName; + private final LanceExternalTable sourceTable; + private final LanceTableMetadata metadata; + private final long topK; + private final long offset; + + private CommonSearch(Map params, String displayName, + TableName sourceTableName, LanceExternalTable sourceTable, + LanceTableMetadata metadata, long topK, long offset) { + this.params = Collections.unmodifiableMap(new TreeMap<>(params)); + this.displayName = displayName; + this.sourceTableName = sourceTableName; + this.sourceTable = sourceTable; + this.metadata = metadata; + this.topK = topK; + this.offset = offset; + } + + protected Map params() { + return params; + } + + protected LanceTableMetadata metadata() { + return metadata; + } + + protected long topK() { + return topK; + } + + protected long offset() { + return offset; + } + } + + protected static final class PreparedSearch { + private final CommonSearch common; + private final int fieldId; + private final TExternalSearchRequest searchRequest; + private final List columns; + + private PreparedSearch(CommonSearch common, int fieldId, + TExternalSearchRequest searchRequest, List columns) { + this.common = common; + this.fieldId = fieldId; + this.searchRequest = searchRequest; + this.columns = columns; + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/TableValuedFunctionIf.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/TableValuedFunctionIf.java index 7a7569583d5283..79c10b465bee27 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/TableValuedFunctionIf.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/TableValuedFunctionIf.java @@ -104,6 +104,8 @@ public static TableValuedFunctionIf getTableFunction(String funcName, Map PROPERTIES = ImmutableSet.of( TABLE, COLUMN, QUERY_VECTOR, TOP_K, OFFSET, METRIC, FILTER, NPROBES, REFINE_FACTOR, EF, USE_INDEX); - private final TableName sourceTableName; - private final LanceExternalTable sourceTable; - private final LanceTableMetadata metadata; - private final int vectorFieldId; - private final List columns; - private final TExternalSearchRequest searchRequest; - public VectorSearchTableValuedFunction(Map properties) throws AnalysisException { - Map params = normalizeProperties(properties); - sourceTableName = parseTableName(required(params, TABLE)); - sourceTable = findLanceExternalTable(sourceTableName); + super(prepare(properties)); + } + + private static PreparedSearch prepare(Map properties) + throws AnalysisException { + Map params = normalizeProperties(properties, PROPERTIES, NAME); boolean useIndex = !params.containsKey(USE_INDEX) || parseBoolean(params.get(USE_INDEX), USE_INDEX); - try { - metadata = useIndex - ? sourceTable.loadMetadataForVectorSearch() : sourceTable.loadMetadata(); - } catch (RuntimeException e) { - throw new AnalysisException("Failed to load Lance metadata for vector search on " - + sourceTableName + ": " + e.getMessage(), e); - } - if (metadata.getVersion() <= 0) { - throw new AnalysisException("Lance vector search requires a fixed positive dataset version"); - } + CommonSearch common = prepareCommon(params, NAME, + "VectorSearchTableValuedFunction", "vector search", useIndex); Field vectorField = LanceVectorQuery.findVectorColumnField( - metadata.getSchema(), required(params, COLUMN)); - vectorFieldId = useIndex ? requireLanceFieldId(metadata, vectorField) : -1; + common.metadata().getSchema(), required(params, COLUMN, NAME)); + int vectorFieldId = useIndex + ? requireLanceFieldId(common.metadata(), vectorField) : -1; TSearchVector queryVector = LanceVectorQuery.parseAndEncodeQueryVector( - vectorField, required(params, QUERY_VECTOR)); - long topK = parseLong(params.getOrDefault(TOP_K, "10"), TOP_K, 1, Long.MAX_VALUE); - long offset = parseLong(params.getOrDefault(OFFSET, "0"), OFFSET, 0, Long.MAX_VALUE); - if (offset > UINT32_MAX || topK > UINT32_MAX - offset) { - throw new AnalysisException("'top_k + offset' must not exceed " + UINT32_MAX); - } + vectorField, required(params, QUERY_VECTOR, NAME)); TVectorSearchParams vectorParams = new TVectorSearchParams() .setColumn(vectorField.getName()) .setQueryVector(queryVector) - .setTopK(topK) - .setOffset(offset); + .setTopK(common.topK()) + .setOffset(common.offset()); if (params.containsKey(METRIC)) { vectorParams.setMetric(parseMetric(params.get(METRIC))); } - searchRequest = new TExternalSearchRequest() + TExternalSearchRequest searchRequest = new TExternalSearchRequest() .setSchemaVersion(1) .setSearchQuery(TExternalSearchQuery.vector_search(vectorParams)); - if (params.containsKey(FILTER)) { - searchRequest.setSearchFilter(new TSearchFilter() - .setFormat(TSearchFilterFormat.SQL) - .setPayload(validateAndEncodeSqlFilter(params.get(FILTER)))); + TVectorSearchOptions vectorSearchOptions = buildVectorSearchOptions(params, useIndex); + if (vectorSearchOptions != null) { + searchRequest.setVectorSearchOptions(vectorSearchOptions); } + return prepareSearch( + common, vectorFieldId, searchRequest, DISTANCE_COLUMN, "vector search"); + } - TVectorSearchOptions vectorSearchOptions = new TVectorSearchOptions(); - boolean hasVectorSearchOptions = false; + private static TVectorSearchOptions buildVectorSearchOptions( + Map params, boolean useIndex) throws AnalysisException { + TVectorSearchOptions options = new TVectorSearchOptions(); + boolean configured = false; if (params.containsKey(NPROBES)) { - vectorSearchOptions.setNprobes(parsePositiveInt(params.get(NPROBES), NPROBES)); - hasVectorSearchOptions = true; + options.setNprobes(parsePositiveInt(params.get(NPROBES), NPROBES)); + configured = true; } if (params.containsKey(REFINE_FACTOR)) { - vectorSearchOptions.setRefineFactor( + options.setRefineFactor( parsePositiveInt(params.get(REFINE_FACTOR), REFINE_FACTOR)); - hasVectorSearchOptions = true; + configured = true; } if (params.containsKey(EF)) { - vectorSearchOptions.setEf(parsePositiveInt(params.get(EF), EF)); - hasVectorSearchOptions = true; + options.setEf(parsePositiveInt(params.get(EF), EF)); + configured = true; } if (params.containsKey(USE_INDEX)) { - vectorSearchOptions.setUseIndex(useIndex); - hasVectorSearchOptions = true; - } - if (hasVectorSearchOptions) { - searchRequest.setVectorSearchOptions(vectorSearchOptions); - } - columns = buildOutputColumns(metadata); - } - - public LanceExternalTable getSourceTable() { - return sourceTable; - } - - public LanceTableMetadata getMetadata() { - return metadata; - } - - public TExternalSearchRequest getSearchRequest() { - return searchRequest.deepCopy(); - } - - public long getTopK() { - return searchRequest.getSearchQuery().getVectorSearch().getTopK(); - } - - public long getOffset() { - return searchRequest.getSearchQuery().getVectorSearch().getOffset(); - } - - @Override - public String getTableName() { - return "VectorSearchTableValuedFunction<" + sourceTableName + ">"; - } - - @Override - public List getTableColumns() { - return columns; - } - - @Override - public ScanNode getScanNode(PlanNodeId id, TupleDescriptor desc, SessionVariable sv) { - return LanceScanNode.forVectorSearch(id, desc, sourceTable, metadata, - vectorFieldId, searchRequest, sv); - } - - private static Map normalizeProperties(Map properties) - throws AnalysisException { - Map normalized = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - for (Map.Entry entry : properties.entrySet()) { - String key = entry.getKey().toLowerCase(Locale.ROOT); - if (!PROPERTIES.contains(key)) { - throw new AnalysisException("'" + entry.getKey() - + "' is an invalid property for vector_search()"); - } - if (normalized.put(key, entry.getValue()) != null) { - throw new AnalysisException("Duplicate vector_search() property '" + key + "'"); - } - } - return normalized; - } - - private static String required(Map params, String key) - throws AnalysisException { - String value = params.get(key); - if (value == null || value.trim().isEmpty()) { - throw new AnalysisException("Missing required vector_search() property '" + key + "'"); + options.setUseIndex(useIndex); + configured = true; } - return value.trim(); - } - - @VisibleForTesting - static TableName parseTableName(String value) throws AnalysisException { - Expression expression; - try { - expression = new NereidsParser().parseExpression(value); - } catch (ParseException e) { - throw new AnalysisException(FULLY_QUALIFIED_TABLE_NAME_ERROR, e); - } - if (!(expression instanceof UnboundSlot)) { - throw new AnalysisException(FULLY_QUALIFIED_TABLE_NAME_ERROR); - } - List names = ((UnboundSlot) expression).getNameParts(); - if (names.size() != 3) { - throw new AnalysisException(FULLY_QUALIFIED_TABLE_NAME_ERROR); - } - return new TableName(names.get(0), names.get(1), names.get(2)); - } - - private static LanceExternalTable findLanceExternalTable(TableName tableName) - throws AnalysisException { - ConnectContext context = ConnectContext.get(); - if (!Env.getCurrentEnv().getAccessManager() - .checkTblPriv(context, tableName, PrivPredicate.SELECT)) { - ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLEACCESS_DENIED_ERROR, "SELECT", - context.getQualifiedUser(), context.getRemoteIP(), - tableName.getDb() + ": " + tableName.getTbl()); - } - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(tableName.getCtl()); - if (!(catalog instanceof LanceExternalCatalog)) { - throw new AnalysisException("Catalog '" + tableName.getCtl() - + "' is not a Lance catalog"); - } - TableIf table = catalog.getDbOrAnalysisException(tableName.getDb()) - .getTableOrAnalysisException(tableName.getTbl()); - if (!(table instanceof LanceExternalTable)) { - throw new AnalysisException("Table '" + tableName + "' is not a Lance table"); - } - return (LanceExternalTable) table; + return configured ? options : null; } @VisibleForTesting static List buildOutputColumns(LanceTableMetadata metadata) throws AnalysisException { - List result = new ArrayList<>(metadata.getSchema().getFields().size() + 1); - Set fieldNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - int position = 0; - for (Field field : metadata.getSchema().getFields()) { - if (!fieldNames.add(field.getName())) { - throw new AnalysisException("Duplicate Lance schema column under " - + "case-insensitive matching: '" + field.getName() + "'"); - } - if (field.getName().startsWith(Column.GLOBAL_ROWID_COL)) { - throw new AnalysisException("Lance table contains column '" + field.getName() - + "' using reserved Doris internal column prefix '" - + Column.GLOBAL_ROWID_COL + "'"); - } - if (field.getName().equalsIgnoreCase(DISTANCE_COLUMN)) { - throw new AnalysisException("Lance table already contains reserved vector search " - + "column '" + DISTANCE_COLUMN + "'"); - } - String comment = field.getMetadata() == null - ? null : field.getMetadata().get("comment"); - Type type; - try { - type = LanceTypeConverter.toDorisType(field); - } catch (RuntimeException e) { - throw new AnalysisException("Invalid Lance type for column '" + field.getName() - + "': " + e.getMessage(), e); - } - result.add(new Column(field.getName(), type, false, null, - field.isNullable(), comment, true, position++)); - } - result.add(new Column(DISTANCE_COLUMN, Type.FLOAT, false, null, - true, null, true, position)); - return result; + return buildOutputColumns(metadata, DISTANCE_COLUMN, "vector search"); } @VisibleForTesting static int requireLanceFieldId(LanceTableMetadata metadata, Field field) throws AnalysisException { - OptionalInt fieldId = metadata.getLanceFieldId(field.getName()); - if (!fieldId.isPresent()) { - throw new AnalysisException("Lance vector column '" + field.getName() - + "' has no field ID in the Lance schema"); - } - return fieldId.getAsInt(); - } - - @VisibleForTesting - static byte[] validateAndEncodeSqlFilter(String filter) throws AnalysisException { - if (filter == null || filter.trim().isEmpty()) { - throw new AnalysisException("'filter' must not be empty"); - } - if (filter.indexOf('\0') >= 0) { - throw new AnalysisException("'filter' must not contain an embedded NUL byte"); - } - return filter.getBytes(StandardCharsets.UTF_8); - } - - private static long parseLong(String value, String property, long min, long max) - throws AnalysisException { - try { - long parsed = Long.parseLong(value); - if (parsed < min || parsed > max) { - throw new AnalysisException("'" + property + "' must be between " - + min + " and " + max); - } - return parsed; - } catch (NumberFormatException e) { - throw new AnalysisException("'" + property + "' must be an integer", e); - } + return requireLanceFieldId(metadata, field, "vector"); } private static int parsePositiveInt(String value, String property) diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java index 2fb3b7c390df6f..e38f0120270748 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java @@ -39,6 +39,7 @@ import org.apache.arrow.vector.types.pojo.Schema; import org.junit.Assert; import org.junit.Test; +import org.lance.index.IndexType; import java.nio.ByteBuffer; import java.util.Arrays; @@ -203,9 +204,11 @@ public void testExternalSearchUsesOneSplitPerIndexSegmentAndKeepsUnindexedFragme Collections.singletonMap("vector", 9), Arrays.asList( new LanceIndexSegmentInfo(firstSegment, "vector_idx", - Collections.singletonList(9), Arrays.asList(1L, 2L), "L2"), + Collections.singletonList(9), Arrays.asList(1L, 2L), + IndexType.VECTOR, "L2"), new LanceIndexSegmentInfo(secondSegment, "vector_idx", - Collections.singletonList(9), Arrays.asList(3L, 4L), "L2")), + Collections.singletonList(9), Arrays.asList(3L, 4L), + IndexType.VECTOR, "L2")), Collections.emptyMap()); LanceScanNode node = newSearchNode(metadata, vectorSearchRequest(5, 0)); @@ -238,7 +241,8 @@ public void testExternalSearchUseIndexFalseKeepsFragmentSplits() throws Exceptio Collections.emptyMap(), Collections.singletonList( new LanceIndexSegmentInfo(UUID.randomUUID(), "vector_idx", - Collections.singletonList(9), Arrays.asList(1L, 2L), "L2")), + Collections.singletonList(9), Arrays.asList(1L, 2L), + IndexType.VECTOR, "L2")), Collections.emptyMap()); TExternalSearchRequest request = vectorSearchRequest(5, 0); request.setVectorSearchOptions(new TVectorSearchOptions().setUseIndex(false)); @@ -263,7 +267,8 @@ public void testExternalSearchFallsBackToFragmentSplitsForMetricMismatch() throw Collections.singletonMap("vector", 9), Collections.singletonList( new LanceIndexSegmentInfo(UUID.randomUUID(), "vector_idx", - Collections.singletonList(9), Arrays.asList(1L, 2L), "L2")), + Collections.singletonList(9), Arrays.asList(1L, 2L), + IndexType.VECTOR, "L2")), Collections.emptyMap()); TExternalSearchRequest request = vectorSearchRequest(5, 0); request.getSearchQuery().getVectorSearch().setMetric(TVectorMetric.COSINE); @@ -286,7 +291,8 @@ public void testExternalSearchRejectsMissingFieldIdForIndexSegmentPlanning() { Collections.emptyMap(), Collections.singletonList( new LanceIndexSegmentInfo(UUID.randomUUID(), "vector_idx", - Collections.singletonList(9), Collections.singletonList(1L), "L2")), + Collections.singletonList(9), Collections.singletonList(1L), + IndexType.VECTOR, "L2")), Collections.emptyMap()); LanceScanNode node = newSearchNode(metadata, vectorSearchRequest(5, 0)); @@ -297,14 +303,16 @@ public void testExternalSearchRejectsMissingFieldIdForIndexSegmentPlanning() { } @Test - public void testFragmentSearchRetainsTopKPlusOffsetCandidates() { + public void testSplitSearchRetainsTopKPlusOffsetCandidates() { TExternalSearchRequest logicalRequest = vectorSearchRequest(5, 2); + LanceScanNode node = LanceScanNode.forExternalSearch( + new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), null, + null, -1, logicalRequest, new SessionVariable()); - TExternalSearchRequest fragmentRequest = - LanceScanNode.createFragmentSearchRequest(logicalRequest); + TExternalSearchRequest splitRequest = node.createSplitSearchRequest(); - Assert.assertEquals(7, fragmentRequest.getSearchQuery().getVectorSearch().getTopK()); - Assert.assertEquals(0, fragmentRequest.getSearchQuery().getVectorSearch().getOffset()); + Assert.assertEquals(7, splitRequest.getSearchQuery().getVectorSearch().getTopK()); + Assert.assertEquals(0, splitRequest.getSearchQuery().getVectorSearch().getOffset()); Assert.assertEquals(5, logicalRequest.getSearchQuery().getVectorSearch().getTopK()); Assert.assertEquals(2, logicalRequest.getSearchQuery().getVectorSearch().getOffset()); } @@ -332,7 +340,7 @@ private static LanceScanNode newSearchNode( LanceTableMetadata metadata, TExternalSearchRequest request) { String vectorColumn = request.getSearchQuery().getVectorSearch().getColumn(); int vectorFieldId = metadata.getLanceFieldId(vectorColumn).orElse(-1); - return LanceScanNode.forVectorSearch( + return LanceScanNode.forExternalSearch( new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), null, metadata, vectorFieldId, request, new SessionVariable()); } diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift index 15701eb34d75b0..6433d8083b0dd3 100644 --- a/gensrc/thrift/PlanNodes.thrift +++ b/gensrc/thrift/PlanNodes.thrift @@ -478,6 +478,11 @@ struct TVectorSearchParams { 5: optional TVectorMetric metric } +enum TFtsCoverageMode { + STRICT, + INDEX_ONLY +} + // Logical parameters for one full-text query. `query` initially carries the backend query string; // richer structured query forms can be added as new fields without changing this basic contract. struct TFullTextSearchParams { @@ -485,6 +490,13 @@ struct TFullTextSearchParams { 2: optional string query 3: optional i64 top_k 4: optional i64 offset + // STRICT requires the selected FTS index to cover the complete pinned snapshot. INDEX_ONLY + // searches and scores only fragments covered by committed FTS index segments. + 5: optional TFtsCoverageMode coverage_mode + // Opaque, versioned global BM25 statistics prepared by Lance for this exact snapshot and + // query. Unset while BE scanners prepare statistics locally; future FE versions may populate + // this field once the bundled lance-c exposes the corresponding consumer API. + 6: optional binary global_statistics } enum TSearchFilterFormat { @@ -534,8 +546,9 @@ struct TLanceFileDesc { // most this many rows; the upper LIMIT operator still enforces the global bound. // Only set for ordinary scans whose predicates are fully pushed into Lance. 4: optional i64 limit - // Physical vector-index segments assigned to this distributed search split. Each value is one - // UUID encoded as 16 bytes in RFC 4122 order. Unset for ordinary and unindexed-fragment scans. + // Physical vector or FTS index segments assigned to this distributed search split. Each value + // is one UUID encoded as 16 bytes in RFC 4122 order. Unset for ordinary and vector + // unindexed-fragment scans. 5: optional list index_segment_uuids } @@ -544,8 +557,8 @@ struct TLanceScanParams { // ScanNode level so it is not serialized once per fragment split. 1: optional binary lance_substrait_filter // Provider-independent search request. Set at ScanNode level so all ranges use the same logical - // query. Lance vector search uses one range per fragment and Doris merges the split-local - // candidates. + // query. Lance external search uses one range per fragment or physical index segment, and Doris + // merges the split-local candidates. 2: optional TExternalSearchRequest external_search_request // Lance-native storage options, handed to lance-c untranslated. The namespace protocol treats // storage_options as opaque configuration passed directly to Lance, so any key vocabulary the From ae25dbd66cb07f45b7a5c67ec9755528a704ba34 Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Mon, 31 Aug 2026 20:27:57 +0800 Subject: [PATCH 3/7] add case --- .../lance_runtime_filter_helper_test.cpp | 1 + .../metadata.lance | Bin 0 -> 734 bytes .../part_0_docs.lance | Bin 0 -> 701 bytes .../part_0_invert.lance | Bin 0 -> 2565 bytes .../part_0_tokens.lance | Bin 0 -> 1155 bytes ...0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn | Bin 0 -> 253 bytes ...1-b311fe36-9353-4f41-ae19-5054d4a73009.txn | Bin 0 -> 126 bytes ...2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn | Bin 0 -> 278 bytes .../_versions/18446744073709551612.manifest | Bin 0 -> 934 bytes .../_versions/18446744073709551613.manifest | Bin 0 -> 541 bytes .../_versions/18446744073709551614.manifest | Bin 0 -> 583 bytes .../_versions/latest_version_hint.json | 1 + ...0000000001ee52754ad1a1da89dd0efa4379.lance | Bin 0 -> 1412 bytes ...0100010000fd779442f589c03379a2858ea8.lance | Bin 0 -> 1348 bytes be/test/format_v2/table/lance_reader_test.cpp | 196 +++++++++ .../lance_build_preinstalled_catalog.py | 177 +++++++- .../metadata.lance | Bin 0 -> 734 bytes .../part_0_docs.lance | Bin 0 -> 701 bytes .../part_0_invert.lance | Bin 0 -> 2565 bytes .../part_0_tokens.lance | Bin 0 -> 1155 bytes ...0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn | Bin 0 -> 253 bytes ...1-b311fe36-9353-4f41-ae19-5054d4a73009.txn | Bin 0 -> 126 bytes ...2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn | Bin 0 -> 278 bytes .../_versions/18446744073709551612.manifest | Bin 0 -> 934 bytes .../_versions/18446744073709551613.manifest | Bin 0 -> 541 bytes .../_versions/18446744073709551614.manifest | Bin 0 -> 583 bytes .../_versions/latest_version_hint.json | 1 + ...0000000001ee52754ad1a1da89dd0efa4379.lance | Bin 0 -> 1412 bytes ...0100010000fd779442f589c03379a2858ea8.lance | Bin 0 -> 1348 bytes .../bitmap_page_lookup.lance | Bin 812 -> 812 bytes .../page_data.lance | Bin 0 -> 1213 bytes .../page_lookup.lance | Bin .../page_data.lance | Bin 1149 -> 0 bytes .../bitmap_page_lookup.lance | Bin 0 -> 457 bytes .../bitmap_page_lookup.lance | Bin 457 -> 0 bytes ...8-39e6dfbf-f1c5-4b1e-808a-ee342f74383f.txn | Bin 946 -> 0 bytes ...3-54b88aa6-136b-41a5-9652-3a7132712a1a.txn | Bin 0 -> 946 bytes .../_versions/18446744073709551581.manifest | Bin 0 -> 1968 bytes .../_versions/18446744073709551586.manifest | Bin 1968 -> 0 bytes .../_versions/latest_version_hint.json | 2 +- ...010010101183fee04e8eb3b452a26bcf00a5.lance | Bin 0 -> 2489 bytes ...0110110006d24fe4da58277b24ffe1d6a0e.lance} | Bin 1520 -> 1584 bytes ...1100000100d7d3884108b45243313e881513.lance | Bin 2361 -> 0 bytes .../metadata.lance | Bin 0 -> 734 bytes .../part_0_docs.lance | Bin 0 -> 637 bytes .../part_0_invert.lance | Bin 0 -> 1851 bytes .../part_0_tokens.lance | Bin 0 -> 1026 bytes ...0-1bba5bff-2ab6-434b-8eaa-822470b46ed7.txn | Bin 0 -> 219 bytes ...1-0a6db125-38e9-4ae5-94d5-5972747763df.txn | Bin 0 -> 284 bytes ...2-315f4e75-d073-4bd2-a75d-f04c8347628b.txn | Bin 0 -> 124 bytes .../_versions/18446744073709551612.manifest | Bin 0 -> 750 bytes .../_versions/18446744073709551613.manifest | Bin 0 -> 827 bytes .../_versions/18446744073709551614.manifest | Bin 0 -> 515 bytes .../_versions/latest_version_hint.json | 1 + ...100000100086dae842f7bee179e75e92939e.lance | Bin 0 -> 991 bytes ...1101000001aafd3f464c855149c5211be544.lance | Bin 0 -> 991 bytes .../FullTextSearchTableValuedFunction.java | 7 +- .../lance/source/LanceScanNodeTest.java | 107 ++++- ...FullTextSearchTableValuedFunctionTest.java | 105 +++++ .../lance/test_lance_full_text_search.out | 18 + .../lance/test_lance_full_text_search.groovy | 120 +++++ .../lance-c-0.1.7-runtime-filter.patch | 414 ++++++++++++++---- 62 files changed, 1052 insertions(+), 98 deletions(-) create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/metadata.lance create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_docs.lance create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_invert.lance create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_tokens.lance create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/1-b311fe36-9353-4f41-ae19-5054d4a73009.txn create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551612.manifest create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551613.manifest create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551614.manifest create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/latest_version_hint.json create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/data/000100011001100000000001ee52754ad1a1da89dd0efa4379.lance create mode 100644 be/test/format_v2/table/lance/data/fts_indexed.lance/data/100101010010010100010000fd779442f589c03379a2858ea8.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/metadata.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_docs.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_invert.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_tokens.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/1-b311fe36-9353-4f41-ae19-5054d4a73009.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551612.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551613.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551614.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/latest_version_hint.json create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/data/000100011001100000000001ee52754ad1a1da89dd0efa4379.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/data/100101010010010100010000fd779442f589c03379a2858ea8.lance rename docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/{8831a412-b4a7-4975-950c-8fe62bf16ef7 => 19cc56b4-0a64-4d27-a659-61c9404f2b4e}/bitmap_page_lookup.lance (80%) create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/3d69787d-d9aa-4cc5-8358-cd34c85bd6f1/page_data.lance rename docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/{5b68f7cb-d3d6-404d-9fe4-2fab2d4c2c3e => 3d69787d-d9aa-4cc5-8358-cd34c85bd6f1}/page_lookup.lance (100%) delete mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/5b68f7cb-d3d6-404d-9fe4-2fab2d4c2c3e/page_data.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6cd7ad50-bc0e-484d-87b1-f9b3ac35ce9b/bitmap_page_lookup.lance delete mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/b2a0b46c-741e-4884-8b1b-15aa5e389eb0/bitmap_page_lookup.lance delete mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/28-39e6dfbf-f1c5-4b1e-808a-ee342f74383f.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/33-54b88aa6-136b-41a5-9652-3a7132712a1a.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551581.manifest delete mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551586.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/10010110000000010010101183fee04e8eb3b452a26bcf00a5.lance rename docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/{01101101000001110001101007a96744fd8fa4f4791c8ccd2a.lance => 1001111110010100110110006d24fe4da58277b24ffe1d6a0e.lance} (60%) delete mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/110001010111011100000100d7d3884108b45243313e881513.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/metadata.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_docs.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_invert.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_tokens.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/0-1bba5bff-2ab6-434b-8eaa-822470b46ed7.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/1-0a6db125-38e9-4ae5-94d5-5972747763df.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/2-315f4e75-d073-4bd2-a75d-f04c8347628b.txn create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/18446744073709551612.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/18446744073709551613.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/18446744073709551614.manifest create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/latest_version_hint.json create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/data/00101001111000100000100086dae842f7bee179e75e92939e.lance create mode 100644 docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/data/001100101100101101000001aafd3f464c855149c5211be544.lance create mode 100644 fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java create mode 100644 regression-test/data/external_table_p0/lance/test_lance_full_text_search.out create mode 100644 regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy diff --git a/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp b/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp index af65092f83e3ba..cbe7669817b81d 100644 --- a/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp +++ b/be/test/format_v2/lance/lance_runtime_filter_helper_test.cpp @@ -29,6 +29,7 @@ #include "core/data_type/data_type_number.h" #include "core/data_type/data_type_string.h" #include "core/field.h" +#include "exprs/create_predicate_function.h" #include "exprs/hybrid_set.h" #include "exprs/runtime_filter_expr.h" #include "exprs/vbloom_predicate.h" diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/metadata.lance b/be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/metadata.lance new file mode 100644 index 0000000000000000000000000000000000000000..340a57964dc091bb60db01747eb955c302c59fcb GIT binary patch literal 734 zcmbtSPm2>V6wjnmN=|J&xCmkiEb35dx4SH5PRhElheh_jNXX8-nFcdSon*GHBKRph zc=ED-O!c)hEv#MyA0#jN{dvg?8Ip_t3PSqiCq|EKR)i!IoG|{iDEr=F&-ow&0|7Fb zSE?w%20#3@izx2iXsfFAjK+^xKe+c`rH3GMSCuO8PVPfIU~HkPpo4bS^Un3;U3^Db z7co_O{*8VTM%fhP)~!E6B@z)}6~su~D1RUKx?C7Q1}#Oc3PA0-EsXYa1eZF@<-+;x zQ6dRd4I@#iRa06Tutk`QTvL-zFL_7TTmQGCyNKpY>9$T^;yCHhA3QUG{*{-f$X4v*;o^q|Gn-&?NGu&393=*zsRm-H$eJ N=*^%wU%q~O_8WmO*suTq literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_docs.lance b/be/test/format_v2/table/lance/data/fts_indexed.lance/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_docs.lance new file mode 100644 index 0000000000000000000000000000000000000000..9b336efbb78855046967f6f36d80be1cbd00637a GIT binary patch literal 701 zcmZo*@Sq4VFgP&$0|EvpV1&|4P?{Mg0i;24Fbt9hu|e`6np{UXkgXG9BLjm1*!>`P zumCYD5X0CY8pH>=6_@+CT)AY0*y4-w%QI6H{=)#H7F%g%UWu8B1tXV=5O;iDX>NQ; zes*eJu>>QADq|xHMga~kO(AtIRW5n`oW#83RK3)^p?C|okE~nzMZuR0zc=&Vy!U2y zXOij2_<@6YK@E;21aoPJva>0bTGuxUu2p zC>nQ*S7D=h!L}dY_iumZL=Mt5#Cm$~T~5>y91rTxtav^K|DgUz zU(1Qwf^oJR5BdZjc!I|cO3=0!eO}s&*Y-Y=)GaZo)uQJ^drE7kT8Xq?S`YOFWksQ{ zEGsQf`VX8xE=72L+Dq|VD6p)=z4+T#?pxNi-8^=-4@gz%E?*11lfm~Z+!ya{&_|4s z?_S$;n#AP=_!4{tz5$!y2XGbq1bzY6z;*B|_znCHZh$|)U*IO_Er8dh(n7fCU>r<> z?O+P*08Q`|*bVlA{onwY1qZ=AI0lY`1#kkK1h0Wp;50Z3RzVA#2OV$;ya~GC9Wb;l z-n&6}2npH9P52N+6XW8(36F>E;tmOq^N@dIQVd}i6m~(8Crn`%^-#Xxi8$!}!#>Nb z)6k3a^@dZkE9P$@a*t}&8ctSbX+y0!<++sH6SqnWm2&ZV%`Po0_sdy@bs4NUS6V7I zX6g&&loFh--LUE@+)Z&MMh6<-W2#Q=jE)Z1j!Rt;IR}GWv$XB;zVmrM#;e zs`5ILOeMO8jU2ngI>S>l=ldo~m2#o9TwipI4whyrQ}kq7H=>@F&+zLtE9d3$ePu5& zwW+$Ap(uHtd&n4M7kA5jlpRSQ9*KKdO@pe?Gctc9`%kR7QJ&cRzlkSVv>A0{?jgS8 zzckwM5hKrrc3X8PJGN!VBm4)=+wy@Y^h?j>mJcbd`^qx>McJKrwEfoJJhG^M;9*Nx>s-?d$Qsb?0nAB>6AL{XQkrlw_hRCVGv>n zDFbLb^BbTCa}2SMl@HF3?{Kr~%42|#1zJOTjx$rbYql^86=`Ox(yq+@83mr1bgXjG zHnSyRm8%A!^Zbai3;T&QOZf!;Yma&*Aupv2q-XiGGD=rkj_KH$)i%j`gcHL0$TUyU3t`A9dU$4lo82cavaan1 uH@aP$4BtdqJ&h~sPrEArv?tFf|AwlM`j2bFg_}yTpAK3HAgo@yz4RB<96$X4 literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn b/be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn new file mode 100644 index 0000000000000000000000000000000000000000..65272d53b4f7b4d47c84a15229f4d8acc4f7f9f0 GIT binary patch literal 253 zcmZuoQ3`@U6l~Qb{Cr;$6$X7oU_5ozw}{X!aOXL#!x57g(598mEPP3^Y*awC_;<|_VV#}K&;9<7o5jcN2?#bMX)qhzSh#Rfs8=gh?SteChpl%DWpOwxCl{ z5-Qvniv?;C5O%go0Qz2Chf6*W69C}syV*aCkYg1#M?<$s-65JWRXsfHd5_#LQn%dx MK6d*Aug%zh0O=(fk^lez literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn b/be/test/format_v2/table/lance/data/fts_indexed.lance/_transactions/2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn new file mode 100644 index 0000000000000000000000000000000000000000..e9216b8131e17ab00715f6e65291a9c172ddbaae GIT binary patch literal 278 zcmd;J5>hcYOEt4hu}Ic6FiA4kH8C(s)wM7)GSD?ovrIHMH%c@zGBVl4_KK0~IU|=4 zmq6sslj}J@c+OhU!N#2T?ogEwBa;+IQhrKhd|FAd0+W`Al>q|-BLfI9G6+CvBPT9J z{hY+SlEloMVj)>Bw&Kj(f}B(#P9@j8^qkD% z3?&Ih1q}^G59R=73r2^PcP?#fH3<^q5-dn8Dv39UPsvX%)&rX>u$MJRn2RqLXjDpK iNg`D69&3;UmoQXuNq%-}9!z;NCz4Di*fmf+ts($E7Et8? literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551612.manifest b/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551612.manifest new file mode 100644 index 0000000000000000000000000000000000000000..83edf4ee407b298a9d91e02142610a54689de7ef GIT binary patch literal 934 zcmd^;Pixdb6u>jdCS=D_)@dnAv62=K%R=I0n%zu#@-MicqU%LZ%WP({M$>Fb25o!Q zg7^{q1ntS2pwf$Y5pU9?coU_E;y0*Cx(gmEcy|Wgdo%C7ACEV04i!a#KLA9q@!;*J z*Y#h$SGz9^Ej{|Mjexpn4##|J5Xsz9+w*H!Q9u!2%Em;KD{%Q@!qQMUk_{8#^wK9H zlY;kBE;g?T$>JnOOVG&U^^HWJ`pWfml*IYNl_s!SEpS7-qfu~a@9UR0J6?YQp50)X z9N+;Thq+U68~b{H4$iI@D9$9CTz=F0O*l6RmgC1ForXWF|KW+NY9={5^9x1!riw23 zUC|9V4Q=cVecQuC*tAQq?MDIgeZmMK-aF$E{6C#}S#$=TLq;}!I*7S-JYArz$Eoak z6u|RHm$6Jl@o-b`F=!jP%;I!JRk(y|<+L=_)7{I+44D+8akkaeP7OhK(Je?_jNNJ% z;zXAQ#u4}Z!1GAdp+SgOSN(ty+M$BcljlV>1*qC$B`viD@JDFX&^;5B&?3KtxFxcp zmBhjpqC@3nngEA(o{4lu8LE?9z%t9Lb3#spQZWW~er^u?7cYyx-A zPT$EK!I-3Fs7p-T>6KLEW$|&bLgIndT+cwypo#~^XExF^T*1iD1WXJNz{KF==;sUo DHV2Y; literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551614.manifest b/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/18446744073709551614.manifest new file mode 100644 index 0000000000000000000000000000000000000000..f89780183298b80671cfda57d0f7b46378fe91db GIT binary patch literal 583 zcmey%z`!7+Vv=ZSVq|D$p=)WDVxeo2W?-(Hl$>gyn`&%eVwr4_nrN16x{2*FBUhl1 zFPDX(fq@|y0tpBWVi*{trI?#rnwS`+nOayT8yFj#TP7M=m;!ZM=;b8lC8r9pFfcMP zOMyuRCJjb|BU}nBLNY>ZMfv6NnJEhYVSrJKH8Zco%*29GNKS~gB(o$ZRe}*&nyt8` zC^IkJf=NhLh$SgMrBZ?kyR@PZM{;6GYI=TAr35opB}R-43|x5Kz(v3fT=?C&y Ta0Md+BQVq;fQiA!(a#wG`rN$S literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/latest_version_hint.json b/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/latest_version_hint.json new file mode 100644 index 00000000000000..f93d3984472d99 --- /dev/null +++ b/be/test/format_v2/table/lance/data/fts_indexed.lance/_versions/latest_version_hint.json @@ -0,0 +1 @@ +{"version":3} \ No newline at end of file diff --git a/be/test/format_v2/table/lance/data/fts_indexed.lance/data/000100011001100000000001ee52754ad1a1da89dd0efa4379.lance b/be/test/format_v2/table/lance/data/fts_indexed.lance/data/000100011001100000000001ee52754ad1a1da89dd0efa4379.lance new file mode 100644 index 0000000000000000000000000000000000000000..5079d336a0c6b035963cd2bde43fe97bb5b700d9 GIT binary patch literal 1412 zcmcgsQEL-H5Z>Kf(y%D%)`t+RuEnCQ5NjIBJzlA;6%O%5@J+XO+uY%0SN8U3Q+&va z;9u~~-{3FEFH16w)0n!d|jG9 zob%rXo(F&(Kmd3FI4rV^TX|-AE`^DbXWFDW&%}F~=pvV)P?6-h)kX~EYh`36tc;=2 znVzfqt)C-YKUpkB=;KBIkD=#1Kms@ioYeZ~QVmlj#Rm~3lE;6l$>wSzofRV`OeKq- zXfqO)3nj9#O{2W1bV7YpjHI%Q>8-1KxAgqBFZ6do^EzM?&;yiHi-i@(BA0GZ+9uNc z>Vi%%y~A2YpT13F{-ZLy)l8M$9r&2uWlfvfOm^_^=~iw{s)jzHYs@>=@wh`SPv2+l z2=-#A&A8LJ^p~3+-DD5x2EF%WK7^iBk&fZ=yth9e$AOC(p@;>PpnwtbsN<~v7~g$0q6o?i%T8Vh5@}(M`kDY*7>-rj&o6JOtin zQBLGoME{F3J|^#TJZ3oyW!^NMF223k0^`U3Qf$x0*NICmO>-LLBh$PdOny-B$*dd8 RCyBV%#UVy0Io>_ldj})WYFhvR literal 0 HcmV?d00001 diff --git a/be/test/format_v2/table/lance_reader_test.cpp b/be/test/format_v2/table/lance_reader_test.cpp index 4a6335422bc7ec..9560baa637a20e 100644 --- a/be/test/format_v2/table/lance_reader_test.cpp +++ b/be/test/format_v2/table/lance_reader_test.cpp @@ -57,6 +57,7 @@ #include "core/data_type/data_type_number.h" #include "core/data_type/data_type_varbinary.h" #include "exec/common/endian.h" +#include "exprs/create_predicate_function.h" #include "exprs/hybrid_set.h" #include "exprs/runtime_filter_expr.h" #include "exprs/vdirect_in_predicate.h" @@ -275,6 +276,74 @@ TFileScanRangeParams make_float32_vector_search_params( return scan_params; } +TFileScanRangeParams make_full_text_search_params( + std::string query_text, int64_t top_k, int64_t offset, + TFtsCoverageMode::type coverage_mode = TFtsCoverageMode::STRICT, + std::optional filter = std::nullopt) { + TFullTextSearchParams full_text_params; + full_text_params.__set_column("body"); + full_text_params.__set_query(std::move(query_text)); + full_text_params.__set_top_k(top_k); + full_text_params.__set_offset(offset); + full_text_params.__set_coverage_mode(coverage_mode); + + TExternalSearchQuery query; + query.__set_full_text_search(std::move(full_text_params)); + TExternalSearchRequest request; + request.__set_schema_version(1); + request.__set_search_query(std::move(query)); + if (filter.has_value()) { + TSearchFilter search_filter; + search_filter.__set_format(TSearchFilterFormat::SQL); + search_filter.__set_payload(*filter); + request.__set_search_filter(std::move(search_filter)); + } + + TLanceScanParams lance_scan_params; + lance_scan_params.__set_external_search_request(std::move(request)); + TFileScanRangeParams scan_params; + scan_params.__set_lance_scan_params(std::move(lance_scan_params)); + return scan_params; +} + +Status get_index_segment_uuids(const std::filesystem::path& dataset_uri, const char* index_name, + std::vector* encoded_uuids) { + std::unique_ptr dataset( + lance_dataset_open(dataset_uri.c_str(), nullptr, 0), lance_dataset_close); + if (dataset == nullptr) { + return Status::InternalError("Failed to open Lance fixture: {}", dataset_uri.string()); + } + const auto segment_count = lance_dataset_index_segment_count(dataset.get(), index_name); + if (segment_count == 0) { + return Status::InternalError("Lance fixture index '{}' has no segments", index_name); + } + std::vector raw_uuids(segment_count * 16); + uint64_t actual_count = 0; + if (lance_dataset_index_segments(dataset.get(), index_name, raw_uuids.data(), segment_count, + &actual_count) != 0 || + actual_count != segment_count) { + return Status::InternalError("Failed to enumerate Lance fixture index '{}' segments", + index_name); + } + encoded_uuids->clear(); + encoded_uuids->reserve(segment_count); + for (size_t index = 0; index < segment_count; ++index) { + encoded_uuids->emplace_back(reinterpret_cast(raw_uuids.data() + index * 16), + 16); + } + return Status::OK(); +} + +TFileRangeDesc make_full_text_search_range(const std::filesystem::path& dataset_uri, + const LanceFixtureInfo& fixture, + const char* index_name) { + auto range = make_lance_range(dataset_uri, fixture.version, fixture.fragment_ids); + std::vector segment_uuids; + EXPECT_TRUE(get_index_segment_uuids(dataset_uri, index_name, &segment_uuids).ok()); + range.table_format_params.lance_params.__set_index_segment_uuids(std::move(segment_uuids)); + return range; +} + TEST(LanceTableReaderVectorSearchTest, RejectsMalformedVectorPayloadBeforeReadingIt) { const Columns columns { projected_column("row_id", TYPE_BIGINT, false), @@ -294,6 +363,133 @@ TEST(LanceTableReaderVectorSearchTest, RejectsMalformedVectorPayloadBeforeReadin EXPECT_NE(status.to_string().find("query vector byte size"), std::string::npos); } +TEST(LanceTableReaderFullTextSearchTest, ValidatesRequestAndScoreTypeBeforeDatasetAccess) { + const Columns columns { + projected_column("row_id", TYPE_BIGINT, false), + projected_column("_score", TYPE_FLOAT, true), + }; + TQueryGlobals query_globals; + RuntimeState state(query_globals); + RuntimeProfile valid_profile("lance_fts_valid_request"); + auto valid_params = make_full_text_search_params("lance", 4, 1); + LanceTableReader valid_reader; + ASSERT_TRUE(init_reader(&valid_reader, columns, &state, &valid_profile, &valid_params).ok()); + ASSERT_NE(valid_profile.get_info_string("LanceSearchType"), nullptr); + EXPECT_EQ("FULL_TEXT", *valid_profile.get_info_string("LanceSearchType")); + ASSERT_NE(valid_profile.get_info_string("LanceFtsCoverageMode"), nullptr); + EXPECT_EQ("STRICT", *valid_profile.get_info_string("LanceFtsCoverageMode")); + ASSERT_NE(valid_profile.get_info_string("LanceTopKPlusOffset"), nullptr); + EXPECT_EQ("5", *valid_profile.get_info_string("LanceTopKPlusOffset")); + + RuntimeProfile empty_query_profile("lance_fts_empty_query"); + auto empty_query_params = make_full_text_search_params("", 4, 0); + LanceTableReader empty_query_reader; + const auto empty_query_status = init_reader(&empty_query_reader, columns, &state, + &empty_query_profile, &empty_query_params); + EXPECT_FALSE(empty_query_status.ok()); + EXPECT_NE(empty_query_status.to_string().find("non-empty query"), std::string::npos); + + RuntimeProfile coverage_profile("lance_fts_invalid_coverage"); + auto invalid_coverage_params = + make_full_text_search_params("lance", 4, 0, static_cast(99)); + LanceTableReader coverage_reader; + const auto coverage_status = init_reader(&coverage_reader, columns, &state, &coverage_profile, + &invalid_coverage_params); + EXPECT_FALSE(coverage_status.ok()); + EXPECT_NE(coverage_status.to_string().find("STRICT or INDEX_ONLY"), std::string::npos); + + const Columns wrong_score_columns { + projected_column("row_id", TYPE_BIGINT, false), + projected_column("_score", TYPE_DOUBLE, true), + }; + RuntimeProfile score_profile("lance_fts_invalid_score_type"); + auto score_params = make_full_text_search_params("lance", 4, 0); + LanceTableReader score_reader; + const auto score_status = + init_reader(&score_reader, wrong_score_columns, &state, &score_profile, &score_params); + EXPECT_FALSE(score_status.ok()); + EXPECT_NE(score_status.to_string().find("must have Doris FLOAT type"), std::string::npos); +} + +std::vector> read_full_text_search_rows(LanceTableReader* reader, + Block* block) { + std::vector> rows; + bool eos = false; + while (!eos) { + EXPECT_TRUE(reader->get_block(block, &eos).ok()); + if (eos) { + continue; + } + const auto& row_ids = assert_cast(*block->get_by_position(0).column); + const auto& scores = assert_cast(*block->get_by_position(1).column); + const auto& score_values = assert_cast(scores.get_nested_column()); + for (size_t row = 0; row < block->rows(); ++row) { + EXPECT_EQ(0, scores.get_null_map_data()[row]); + rows.emplace_back(row_ids.get_data()[row], score_values.get_data()[row]); + } + } + return rows; +} + +TEST(LanceTableReaderFullTextSearchTest, SearchesIndexedSnapshotWithOptionalScoreProjection) { + const std::filesystem::path dataset_uri = + "./be/test/format_v2/table/lance/data/fts_indexed.lance"; + LanceFixtureInfo fixture; + ASSERT_TRUE(get_fixture_info(dataset_uri, &fixture).ok()); + auto range = make_full_text_search_range(dataset_uri, fixture, "body_fts"); + + const Columns scored_columns { + projected_column("row_id", TYPE_BIGINT, false), + projected_column("_score", TYPE_FLOAT, true), + }; + TQueryGlobals query_globals; + RuntimeState state(query_globals); + RuntimeProfile scored_profile("lance_fts_indexed_fixture"); + auto scored_params = make_full_text_search_params("lance", 4, 0); + LanceTableReader scored_reader; + ASSERT_TRUE(init_reader(&scored_reader, scored_columns, &state, &scored_profile, &scored_params) + .ok()); + ASSERT_TRUE(prepare_range(&scored_reader, range).ok()); + Block scored_block; + add_output_columns(&scored_block, scored_columns); + const auto scored_rows = read_full_text_search_rows(&scored_reader, &scored_block); + ASSERT_EQ(4U, scored_rows.size()); + EXPECT_EQ((std::vector {3, 2, 1, 7}), + (std::vector {scored_rows[0].first, scored_rows[1].first, + scored_rows[2].first, scored_rows[3].first})); + for (size_t index = 0; index < scored_rows.size(); ++index) { + EXPECT_GT(scored_rows[index].second, 0.0F); + if (index > 0) { + EXPECT_GT(scored_rows[index - 1].second, scored_rows[index].second); + } + } + EXPECT_TRUE(scored_reader.close().ok()); + + // Lance returns _score for ordering even when Doris does not materialize it. The reader must + // ignore that generated column while still rejecting any unexpected _rowid output. + const Columns row_id_columns {projected_column("row_id", TYPE_BIGINT, false)}; + RuntimeProfile row_id_profile("lance_fts_without_score_projection_fixture"); + auto row_id_params = make_full_text_search_params("lance", 2, 0); + LanceTableReader row_id_reader; + ASSERT_TRUE(init_reader(&row_id_reader, row_id_columns, &state, &row_id_profile, &row_id_params) + .ok()); + ASSERT_TRUE(prepare_range(&row_id_reader, range).ok()); + Block row_id_block; + add_output_columns(&row_id_block, row_id_columns); + std::vector row_ids; + bool eos = false; + while (!eos) { + ASSERT_TRUE(row_id_reader.get_block(&row_id_block, &eos).ok()); + if (!eos) { + const auto& values = + assert_cast(*row_id_block.get_by_position(0).column); + row_ids.insert(row_ids.end(), values.get_data().begin(), values.get_data().end()); + } + } + EXPECT_EQ((std::vector {3, 2}), row_ids); + EXPECT_TRUE(row_id_reader.close().ok()); +} + TEST(LanceTableReaderVectorSearchTest, RejectsMalformedIndexSegmentUuid) { const std::filesystem::path dataset_uri = "./be/test/format_v2/table/lance/data/all_types.lance"; diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py index 9721074094f4b3..cb7d79df9341b8 100644 --- a/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py +++ b/docker/thirdparties/docker-compose/iceberg/scripts/lance_build_preinstalled_catalog.py @@ -28,7 +28,7 @@ The generated catalog contains: - __manifest Directory Namespace V2 manifest table (with its scalar indexes). - all_types.lance The pre-existing compatibility-mode root table, re-registered as-is. - - The `doris` namespace with one indexed vector table per cell of the + - The `doris` namespace with two full-text-search fixtures, one indexed vector table per cell of the algorithm x element type x metric matrix (hash-prefixed directories), listed in VECTOR_TABLES below; BREADTH_TABLE, one table carrying the remaining cells at plan level; and NESTED_TABLE, a nested-field scalar index fixture used by the SHOW INDEX / @@ -445,6 +445,41 @@ def _digest_of(vector) -> str: NESTED_COLUMN = "attributes.`child.with.dot`" NESTED_ROWS = 16 +# Full-text-search fixtures. full_text_search indexes both fragments. The partial table +# deliberately appends its second fragment after index creation so Doris can exercise the +# STRICT and INDEX_ONLY coverage modes against the same committed index segment. +FTS_TABLE = "full_text_search" +FTS_PARTIAL_TABLE = "full_text_search_partial" +FTS_INDEX_NAME = "body_fts" +FTS_PARTIAL_INDEX_NAME = "body_fts_partial" +FTS_ROWS = ( + (1, "Lance search", "lance search engine", "tech"), + (2, "Lance search twice", "lance lance search engine", "tech"), + (3, "Lance search three times", "lance lance lance search engine", "tech"), + (4, "Vector search", "vector search engine", "vector"), + (5, "Full text search", "full text search engine", "search"), + (6, "Doris lakehouse", "apache doris lakehouse", "database"), + (7, "Lance storage", "lance columnar storage format analytics", "storage"), + (8, "Unrelated", "unrelated document", "other"), +) +FTS_PARTIAL_ROWS = ( + (101, "lance indexed document", "indexed"), + (102, "another lance indexed document", "indexed"), + (103, "unrelated indexed document", "indexed"), + (104, "lance appended after indexing", "unindexed"), + (105, "second lance appended after indexing", "unindexed"), +) +FTS_INDEX_PARAMS = { + "base_tokenizer": "simple", + "language": "English", + "max_token_length": 40, + "lower_case": True, + "stem": False, + "remove_stop_words": False, + "ascii_folding": False, + "with_position": True, +} + def buildable_combos(): """Every (element type, metric, algorithm) cell the embedded Lance actually accepts. @@ -697,6 +732,77 @@ def create_vector_table(namespace, table_name: str, spec: dict) -> str: return location +def make_fts_table(rows, *, include_title: bool) -> pa.Table: + if include_title: + schema = pa.schema( + [ + pa.field("row_id", pa.int64(), nullable=False), + pa.field("title", pa.string(), nullable=False), + pa.field("body", pa.string(), nullable=False), + pa.field("category", pa.string(), nullable=False), + ] + ) + columns = list(zip(*rows)) + return pa.Table.from_arrays( + [ + pa.array(columns[0], type=pa.int64()), + pa.array(columns[1], type=pa.string()), + pa.array(columns[2], type=pa.string()), + pa.array(columns[3], type=pa.string()), + ], + schema=schema, + ) + + schema = pa.schema( + [ + pa.field("row_id", pa.int64(), nullable=False), + pa.field("body", pa.string(), nullable=False), + pa.field("category", pa.string(), nullable=False), + ] + ) + columns = list(zip(*rows)) + return pa.Table.from_arrays( + [ + pa.array(columns[0], type=pa.int64()), + pa.array(columns[1], type=pa.string()), + pa.array(columns[2], type=pa.string()), + ], + schema=schema, + ) + + +def create_fts_table(namespace, table_name: str, rows, *, indexed_rows: int, + index_name: str, include_title: bool) -> str: + first = make_fts_table(rows[:indexed_rows], include_title=include_title) + buffer = io.BytesIO() + with ipc.new_stream(buffer, first.schema) as writer: + writer.write_table(first) + response = namespace.create_table( + CreateTableRequest(id=[NAMESPACE, table_name]), buffer.getvalue() + ) + location = response.location + dataset = lance.dataset(location) + if table_name == FTS_TABLE: + # Make the fully indexed fixture cover two physical fragments. + lance.write_dataset( + make_fts_table(rows[indexed_rows:], include_title=include_title), + location, + mode="append", + ) + dataset = lance.dataset(location) + dataset.create_scalar_index( + "body", "INVERTED", name=index_name, **FTS_INDEX_PARAMS + ) + if table_name == FTS_PARTIAL_TABLE: + # Keep these rows outside the committed segment for coverage-mode tests. + lance.write_dataset( + make_fts_table(rows[indexed_rows:], include_title=include_title), + location, + mode="append", + ) + return location + + def make_nested_fragment_table(row_offset_start: int, row_offset_end: int) -> pa.Table: offsets = list(range(row_offset_start, row_offset_end)) attributes = pa.StructArray.from_arrays( @@ -794,6 +900,22 @@ def build(root: Path, all_types_source: Path) -> None: RegisterTableRequest(id=["all_types"], location=ALL_TYPES_DIR) ) namespace.create_namespace(CreateNamespaceRequest(id=[NAMESPACE])) + create_fts_table( + namespace, + FTS_TABLE, + FTS_ROWS, + indexed_rows=4, + index_name=FTS_INDEX_NAME, + include_title=True, + ) + create_fts_table( + namespace, + FTS_PARTIAL_TABLE, + FTS_PARTIAL_ROWS, + indexed_rows=3, + index_name=FTS_PARTIAL_INDEX_NAME, + include_title=False, + ) for table_name, spec in VECTOR_TABLES.items(): location = create_vector_table(namespace, table_name, spec) # DirectoryNamespace.create_table_index exists but raises UnsupportedOperationError, @@ -1431,11 +1553,43 @@ def check_nested_dataset(location: str): assert probe == [7], f"{NESTED_TABLE}: BTREE probe returned {probe}" +def check_fts_dataset(location: str, *, table_name: str, index_name: str, + expected_rows: int, expected_indexed_fragments: int) -> None: + dataset = lance.dataset(location) + assert dataset.count_rows() == expected_rows, ( + f"{table_name}: expected {expected_rows} rows" + ) + fragments = dataset.get_fragments() + assert len(fragments) == 2, f"{table_name}: expected 2 fragments" + indices = {index["name"]: index for index in dataset.list_indices()} + index = indices.get(index_name) + assert index is not None, f"{table_name}: missing FTS index {index_name}: {indices}" + assert index["type"] == "Inverted", ( + f"{table_name}: {index_name} has type {index['type']}, expected Inverted" + ) + indexed_fragments = set(index["fragment_ids"]) + assert len(indexed_fragments) == expected_indexed_fragments, ( + f"{table_name}: {index_name} covers fragments {sorted(indexed_fragments)}, expected " + f"{expected_indexed_fragments} fragments" + ) + if table_name == FTS_TABLE: + ranked = dataset.scanner( + columns=["row_id", "_score"], + full_text_query={"query": "lance", "columns": ["body"]}, + limit=4, + ).to_table() + assert ranked["row_id"].to_pylist() == [3, 2, 1, 7], ( + f"{table_name}: indexed BM25 probe returned {ranked.to_pydict()}" + ) + + def check_catalog(root: Path) -> None: check_data_shapes() namespace = lance_namespace.connect("dir", {"root": str(root)}) tables = namespace.list_tables(ListTablesRequest(id=[NAMESPACE])) - expected_tables = sorted([*VECTOR_TABLES, BREADTH_TABLE, NESTED_TABLE]) + expected_tables = sorted( + [*VECTOR_TABLES, BREADTH_TABLE, NESTED_TABLE, FTS_TABLE, FTS_PARTIAL_TABLE] + ) assert sorted(tables.tables) == expected_tables, ( f"unexpected {NAMESPACE} tables: {tables.tables}" ) @@ -1490,6 +1644,25 @@ def check_catalog(root: Path) -> None: nested_path = Path(nested.location.removeprefix("file://")) assert nested_path.is_dir(), f"{NESTED_TABLE} location missing: {nested.location}" check_nested_dataset(nested.location) + + full_fts = namespace.describe_table(DescribeTableRequest(id=[NAMESPACE, FTS_TABLE])) + check_fts_dataset( + full_fts.location, + table_name=FTS_TABLE, + index_name=FTS_INDEX_NAME, + expected_rows=len(FTS_ROWS), + expected_indexed_fragments=2, + ) + partial_fts = namespace.describe_table( + DescribeTableRequest(id=[NAMESPACE, FTS_PARTIAL_TABLE]) + ) + check_fts_dataset( + partial_fts.location, + table_name=FTS_PARTIAL_TABLE, + index_name=FTS_PARTIAL_INDEX_NAME, + expected_rows=len(FTS_PARTIAL_ROWS), + expected_indexed_fragments=1, + ) print(f"self-check OK: {root}") diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/metadata.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/metadata.lance new file mode 100644 index 0000000000000000000000000000000000000000..340a57964dc091bb60db01747eb955c302c59fcb GIT binary patch literal 734 zcmbtSPm2>V6wjnmN=|J&xCmkiEb35dx4SH5PRhElheh_jNXX8-nFcdSon*GHBKRph zc=ED-O!c)hEv#MyA0#jN{dvg?8Ip_t3PSqiCq|EKR)i!IoG|{iDEr=F&-ow&0|7Fb zSE?w%20#3@izx2iXsfFAjK+^xKe+c`rH3GMSCuO8PVPfIU~HkPpo4bS^Un3;U3^Db z7co_O{*8VTM%fhP)~!E6B@z)}6~su~D1RUKx?C7Q1}#Oc3PA0-EsXYa1eZF@<-+;x zQ6dRd4I@#iRa06Tutk`QTvL-zFL_7TTmQGCyNKpY>9$T^;yCHhA3QUG{*{-f$X4v*;o^q|Gn-&?NGu&393=*zsRm-H$eJ N=*^%wU%q~O_8WmO*suTq literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_docs.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_indices/59b9c9af-09f0-499a-a888-06036eeec27a/part_0_docs.lance new file mode 100644 index 0000000000000000000000000000000000000000..9b336efbb78855046967f6f36d80be1cbd00637a GIT binary patch literal 701 zcmZo*@Sq4VFgP&$0|EvpV1&|4P?{Mg0i;24Fbt9hu|e`6np{UXkgXG9BLjm1*!>`P zumCYD5X0CY8pH>=6_@+CT)AY0*y4-w%QI6H{=)#H7F%g%UWu8B1tXV=5O;iDX>NQ; zes*eJu>>QADq|xHMga~kO(AtIRW5n`oW#83RK3)^p?C|okE~nzMZuR0zc=&Vy!U2y zXOij2_<@6YK@E;21aoPJva>0bTGuxUu2p zC>nQ*S7D=h!L}dY_iumZL=Mt5#Cm$~T~5>y91rTxtav^K|DgUz zU(1Qwf^oJR5BdZjc!I|cO3=0!eO}s&*Y-Y=)GaZo)uQJ^drE7kT8Xq?S`YOFWksQ{ zEGsQf`VX8xE=72L+Dq|VD6p)=z4+T#?pxNi-8^=-4@gz%E?*11lfm~Z+!ya{&_|4s z?_S$;n#AP=_!4{tz5$!y2XGbq1bzY6z;*B|_znCHZh$|)U*IO_Er8dh(n7fCU>r<> z?O+P*08Q`|*bVlA{onwY1qZ=AI0lY`1#kkK1h0Wp;50Z3RzVA#2OV$;ya~GC9Wb;l z-n&6}2npH9P52N+6XW8(36F>E;tmOq^N@dIQVd}i6m~(8Crn`%^-#Xxi8$!}!#>Nb z)6k3a^@dZkE9P$@a*t}&8ctSbX+y0!<++sH6SqnWm2&ZV%`Po0_sdy@bs4NUS6V7I zX6g&&loFh--LUE@+)Z&MMh6<-W2#Q=jE)Z1j!Rt;IR}GWv$XB;zVmrM#;e zs`5ILOeMO8jU2ngI>S>l=ldo~m2#o9TwipI4whyrQ}kq7H=>@F&+zLtE9d3$ePu5& zwW+$Ap(uHtd&n4M7kA5jlpRSQ9*KKdO@pe?Gctc9`%kR7QJ&cRzlkSVv>A0{?jgS8 zzckwM5hKrrc3X8PJGN!VBm4)=+wy@Y^h?j>mJcbd`^qx>McJKrwEfoJJhG^M;9*Nx>s-?d$Qsb?0nAB>6AL{XQkrlw_hRCVGv>n zDFbLb^BbTCa}2SMl@HF3?{Kr~%42|#1zJOTjx$rbYql^86=`Ox(yq+@83mr1bgXjG zHnSyRm8%A!^Zbai3;T&QOZf!;Yma&*Aupv2q-XiGGD=rkj_KH$)i%j`gcHL0$TUyU3t`A9dU$4lo82cavaan1 uH@aP$4BtdqJ&h~sPrEArv?tFf|AwlM`j2bFg_}yTpAK3HAgo@yz4RB<96$X4 literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/0-4a942168-96d8-4f07-bce0-e3049c8ea6c5.txn new file mode 100644 index 0000000000000000000000000000000000000000..65272d53b4f7b4d47c84a15229f4d8acc4f7f9f0 GIT binary patch literal 253 zcmZuoQ3`@U6l~Qb{Cr;$6$X7oU_5ozw}{X!aOXL#!x57g(598mEPP3^Y*awC_;<|_VV#}K&;9<7o5jcN2?#bMX)qhzSh#Rfs8=gh?SteChpl%DWpOwxCl{ z5-Qvniv?;C5O%go0Qz2Chf6*W69C}syV*aCkYg1#M?<$s-65JWRXsfHd5_#LQn%dx MK6d*Aug%zh0O=(fk^lez literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_transactions/2-76e69d8c-04b7-402e-8620-7f9a772a2224.txn new file mode 100644 index 0000000000000000000000000000000000000000..e9216b8131e17ab00715f6e65291a9c172ddbaae GIT binary patch literal 278 zcmd;J5>hcYOEt4hu}Ic6FiA4kH8C(s)wM7)GSD?ovrIHMH%c@zGBVl4_KK0~IU|=4 zmq6sslj}J@c+OhU!N#2T?ogEwBa;+IQhrKhd|FAd0+W`Al>q|-BLfI9G6+CvBPT9J z{hY+SlEloMVj)>Bw&Kj(f}B(#P9@j8^qkD% z3?&Ih1q}^G59R=73r2^PcP?#fH3<^q5-dn8Dv39UPsvX%)&rX>u$MJRn2RqLXjDpK iNg`D69&3;UmoQXuNq%-}9!z;NCz4Di*fmf+ts($E7Et8? literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551612.manifest b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551612.manifest new file mode 100644 index 0000000000000000000000000000000000000000..83edf4ee407b298a9d91e02142610a54689de7ef GIT binary patch literal 934 zcmd^;Pixdb6u>jdCS=D_)@dnAv62=K%R=I0n%zu#@-MicqU%LZ%WP({M$>Fb25o!Q zg7^{q1ntS2pwf$Y5pU9?coU_E;y0*Cx(gmEcy|Wgdo%C7ACEV04i!a#KLA9q@!;*J z*Y#h$SGz9^Ej{|Mjexpn4##|J5Xsz9+w*H!Q9u!2%Em;KD{%Q@!qQMUk_{8#^wK9H zlY;kBE;g?T$>JnOOVG&U^^HWJ`pWfml*IYNl_s!SEpS7-qfu~a@9UR0J6?YQp50)X z9N+;Thq+U68~b{H4$iI@D9$9CTz=F0O*l6RmgC1ForXWF|KW+NY9={5^9x1!riw23 zUC|9V4Q=cVecQuC*tAQq?MDIgeZmMK-aF$E{6C#}S#$=TLq;}!I*7S-JYArz$Eoak z6u|RHm$6Jl@o-b`F=!jP%;I!JRk(y|<+L=_)7{I+44D+8akkaeP7OhK(Je?_jNNJ% z;zXAQ#u4}Z!1GAdp+SgOSN(ty+M$BcljlV>1*qC$B`viD@JDFX&^;5B&?3KtxFxcp zmBhjpqC@3nngEA(o{4lu8LE?9z%t9Lb3#spQZWW~er^u?7cYyx-A zPT$EK!I-3Fs7p-T>6KLEW$|&bLgIndT+cwypo#~^XExF^T*1iD1WXJNz{KF==;sUo DHV2Y; literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551614.manifest b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/18446744073709551614.manifest new file mode 100644 index 0000000000000000000000000000000000000000..f89780183298b80671cfda57d0f7b46378fe91db GIT binary patch literal 583 zcmey%z`!7+Vv=ZSVq|D$p=)WDVxeo2W?-(Hl$>gyn`&%eVwr4_nrN16x{2*FBUhl1 zFPDX(fq@|y0tpBWVi*{trI?#rnwS`+nOayT8yFj#TP7M=m;!ZM=;b8lC8r9pFfcMP zOMyuRCJjb|BU}nBLNY>ZMfv6NnJEhYVSrJKH8Zco%*29GNKS~gB(o$ZRe}*&nyt8` zC^IkJf=NhLh$SgMrBZ?kyR@PZM{;6GYI=TAr35opB}R-43|x5Kz(v3fT=?C&y Ta0Md+BQVq;fQiA!(a#wG`rN$S literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/latest_version_hint.json b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/latest_version_hint.json new file mode 100644 index 00000000000000..f93d3984472d99 --- /dev/null +++ b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/_versions/latest_version_hint.json @@ -0,0 +1 @@ +{"version":3} \ No newline at end of file diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/data/000100011001100000000001ee52754ad1a1da89dd0efa4379.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/14d4e11d_doris$full_text_search/data/000100011001100000000001ee52754ad1a1da89dd0efa4379.lance new file mode 100644 index 0000000000000000000000000000000000000000..5079d336a0c6b035963cd2bde43fe97bb5b700d9 GIT binary patch literal 1412 zcmcgsQEL-H5Z>Kf(y%D%)`t+RuEnCQ5NjIBJzlA;6%O%5@J+XO+uY%0SN8U3Q+&va z;9u~~-{3FEFH16w)0n!d|jG9 zob%rXo(F&(Kmd3FI4rV^TX|-AE`^DbXWFDW&%}F~=pvV)P?6-h)kX~EYh`36tc;=2 znVzfqt)C-YKUpkB=;KBIkD=#1Kms@ioYeZ~QVmlj#Rm~3lE;6l$>wSzofRV`OeKq- zXfqO)3nj9#O{2W1bV7YpjHI%Q>8-1KxAgqBFZ6do^EzM?&;yiHi-i@(BA0GZ+9uNc z>Vi%%y~A2YpT13F{-ZLy)l8M$9r&2uWlfvfOm^_^=~iw{s)jzHYs@>=@wh`SPv2+l z2=-#A&A8LJ^p~3+-DD5x2EF%WK7^iBk&fZ=yth9e$AOC(p@;>PpnwtbsN<~v7~g$0q6o?i%T8Vh5@}(M`kDY*7>-rj&o6JOtin zQBLGoME{F3J|^#TJZ3oyW!^NMF223k0^`U3Qf$x0*NICmO>-LLBh$PdOny-B$*dd8 RCyBV%#UVy0Io>_ldj})WYFhvR literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/8831a412-b4a7-4975-950c-8fe62bf16ef7/bitmap_page_lookup.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/19cc56b4-0a64-4d27-a659-61c9404f2b4e/bitmap_page_lookup.lance similarity index 80% rename from docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/8831a412-b4a7-4975-950c-8fe62bf16ef7/bitmap_page_lookup.lance rename to docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/19cc56b4-0a64-4d27-a659-61c9404f2b4e/bitmap_page_lookup.lance index 2efd05d5ca449385daff936c1f8b2fe81d571c9b..94c34d8fb83014e1fb5f209207653af9b836f65a 100644 GIT binary patch delta 75 zcmZ3(wuWuOA@K%=e?Y*)z`&pd#0@~q2*lzOZ^{ab0~vZiY-IouWMJT%%*iOv%E!RZ SATe2!QFoQ8S SfV52%)Ssxpxw(OH4I=;zcoS;? diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/3d69787d-d9aa-4cc5-8358-cd34c85bd6f1/page_data.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/3d69787d-d9aa-4cc5-8358-cd34c85bd6f1/page_data.lance new file mode 100644 index 0000000000000000000000000000000000000000..d9b756b383036ce67947755fe9f223d9dcbd414b GIT binary patch literal 1213 zcmbu9L2DC16vtUd}bD*xm#Y zJc&n-#qZEVZ+?JYy!8|K4ZOC#$-b&-R;>8ow}0OM&D%HZ413^j{Z|ljOiv*OgplXJ z3t$9%01kjd;4APA@F*cmz$2g!yaaOKJ@6Sg0*(QhB*N~GlnK);E*|%y+?i_63ZrB; z+?kYSTrrZFjb&0)wK7(PBG#eWE1a<@GO;Ud7XN0BBM~LiifFwfhN+3Q`ql8W_M}y6 z{<)#ko^8m3DQ`!c*EssZ>nD}bD-o6XX4WrRT3BWK!%CRisofN>b?156OVOa0U0J@4 zkdMU8gX=+-066}My1b6L(?!75ucKZ=^-yc5S5X&G=TNVpR#AP_c~pj4K`r0ytjjjo z3a^f3k}K0VI}iG>YT$zD?KK*B>^Aq~(A4QvJ&(2Rv;%sBvsM0p-DfM06RC%)sq`=n zq17~dX_D{iKIYtGg!h?8y4?3p{5roI@D6LUI~V&}>&<6!TMguXnp=L0-CVBmDyuNB z)@V^0(5-)(*khG1l{a%R^Am-cZQf#Qf1A0;7M9(-F3x-FzBWWw2>Fh=`%$>)V%{rK Ya=u=<(|~w~>Mn;na1XA@VDoA32UP+?@c;k- literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/5b68f7cb-d3d6-404d-9fe4-2fab2d4c2c3e/page_lookup.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/3d69787d-d9aa-4cc5-8358-cd34c85bd6f1/page_lookup.lance similarity index 100% rename from docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/5b68f7cb-d3d6-404d-9fe4-2fab2d4c2c3e/page_lookup.lance rename to docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/3d69787d-d9aa-4cc5-8358-cd34c85bd6f1/page_lookup.lance diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/5b68f7cb-d3d6-404d-9fe4-2fab2d4c2c3e/page_data.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/5b68f7cb-d3d6-404d-9fe4-2fab2d4c2c3e/page_data.lance deleted file mode 100644 index 4028aef2a374233e0ba365b249c8c9990fa9a20d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1149 zcmbu7!A{#i5QcYcHx1LGrb`b96_HjT)E)waC~{8)xsf=8s_F|^R=lQ`oOQ_BfgF%H za^!;4XW-7gr@llVqHoYLiw9`H2vA4*@@xLt@vJgix3~T)7(3>FFg`KHwvn$$h>VdF z%F_MuB<{Glhy&oOq$%>Hq;YzDzKUg;}iViS$QirtscK5sR5f(&c6ePLS^}mO keT-ot?2PgO<&1KV^2_r}W;+-iQu8>>1F-O!bT>bH=ZhFBm;e9( diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6cd7ad50-bc0e-484d-87b1-f9b3ac35ce9b/bitmap_page_lookup.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/6cd7ad50-bc0e-484d-87b1-f9b3ac35ce9b/bitmap_page_lookup.lance new file mode 100644 index 0000000000000000000000000000000000000000..1fd15b9ca22f5739dd742b3b07111bbe42f45dfb GIT binary patch literal 457 zcmb7=%}WC@5XE0&Z8i0)L9`&EEU36bT`wYglOlK!&)$}78zh)brOARt1icCV(HeKN zik@`f&+8Ry@=9%(~Zij(p`V z|5)smux;MDlQX;f-V}-3)=ibUO~@H#5%1*672@9Z$qy!1Moed)OuWx}T9}bcl^M&2 z%sSyL?>$DQEV2-e(&Q=LVjDH5MJ{!wm35gZwYHp`utR>p_SnvGMGT}FO~(EYb}*di uvM?8+=ak_eXt&-dj;e(Mr@#8=@ctIOv72yd@*?D3f>S@QMR0X~J^TP0H(+D{ literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/b2a0b46c-741e-4884-8b1b-15aa5e389eb0/bitmap_page_lookup.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_indices/b2a0b46c-741e-4884-8b1b-15aa5e389eb0/bitmap_page_lookup.lance deleted file mode 100644 index 7b80eb38797d6ff945a5373b7365294f04aeefed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 457 zcmb7A!Ab)$6r9A`YN}wPh#;aYsJNB7UPSgLMerb=yf4`{NHCj9lLdX z*dA{^%9%a*YKp{d>!!*)B;=B^h<9@33h`uj$RAIxjF`^mo_LpSYhgw*)gqTV)5^Nc zlv-O(&f_E2t3_qT@;S3kILmu4ktvHT{2ZmpD?8!G?2zrBRpx;-qsiFsU!V qdQKS@uy*TOf^eMOG?vCGfXzsHAyl|)wM9NNYqVDH8wFyGdD4|FizXW*2d1&$H*nb zCD38GL}<%$&(f(p{m-;N=6x4pWRw!hPs&P7E{QLxEJ%${$}GuEEKrcq60tI1U|?hb z0Y(M^ASuis!eFGxC9j{8n3tTYSCW{Nld9(gR_>XXl3L-CT9TNVQ*6QDu<6d`P0c1j zvRo1n)8Y#f(^KPf^7FGx3-rLc1=g@}%>+9rI^+B4%hw!y=ReV3t?Q#>C&b7g#Se8* zW=ecgNl|JlPS+@K$)UO?1gILjW5l=wF^||aG;%J7U}^zOtbn|uGt?y-?fDu>vT`ApMxtF#kGVGvB&_8MdISH`4+)Ae9bB} z9qb(a=O1NumpSmSwo%?{(lF~0);PrO7Gh$s3E~tIV(<^U&=j!CWDUDh-fDWx*t@{@ YU|~|BHQpG+?m}YY59&&MiN2E=02$pPj{pDw diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/33-54b88aa6-136b-41a5-9652-3a7132712a1a.txn b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_transactions/33-54b88aa6-136b-41a5-9652-3a7132712a1a.txn new file mode 100644 index 0000000000000000000000000000000000000000..b3102ead4af799b551ef6d06ca23796b3bfdeccc GIT binary patch literal 946 zcmd;36jCuYNwTm=Of=IqG&W1pH8D&y)wMJ;HPSUsG&eLhGB-3zG)&yZ*2d1&$H*nb zB_MewYztS4ullmc#FGyG+I~Wej8a1RNm;4MCGjPd1*!2#nI*Z21qzBpFT(bRT;+4LIpVCziRfz|g%}y6_@NHU zOo>k_DN0Sn=^6ztIaJq#09B(o<~+nPB3%4nSEMACBtqS=morF=OAym-Upcv^f!&sK zeQm%VJ`dmajXyW9F+De1h>=A~JSnj_H6G%?;`p4zq|}`FoXp}9oNiR%QbKW~4@ip- zPzQ=T867SV4z!cZLOo!IRY|{Gd6L;LsE@hb_1G)Dow#CAMoS10iww|MBrXm&-PydU z8GjtUW)+$ac8*MfTVI-+>z4fth3{%LH8QcrA$GSA6N6BvV2Q!M9JpdoXbRY6FD`MN f(|F)BrR9h8z9*&!595tN>@FlW{x%a1uuf(GdH*A1 literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551581.manifest b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551581.manifest new file mode 100644 index 0000000000000000000000000000000000000000..6cf3b6446df8a8ad5743cc767d037ba31ffafbba GIT binary patch literal 1968 zcmeH|T}TvB6vyZ8?Am%;bh}ATtJP{ortLN}`!xxHeJBO1hGZB$tao@yCbL| z1XdD+Pf|$lQDIOjfzbyMNk&?T6oEkx1y&IB5Y$7|?Ce@vySmTOeK^A%&N=sY@16gD zCILmkK>!i7ZhU%l97+w_FCW8GwN2HH2ml+3iX9}V2GpJy35c>9#xZ+YRZ$H~QCguk z0AG|1PXWY%3Ad~7Ah~ah; zP@>V!u9#D=wvOb%VI63X+}-!;n*ULM>$Lmv@fV*FplpR{AXy5CYMhX)GPy1&`JNurs^@E}My7Bwe$xnSdD&>sI&CHUEZqq0kboGnH@rL#Mx%+VS-KX`(Udgjz2EOP1`G zG1-la+e|KmTFk|$2?`v`axACC)JRRW7lJE92t>;|Ts zrD`nZcMEfKdDfZX*VA+>BUZayj$Az0sdh&?3ZO~9S7h=zStr|TS)9|nle+?*(5FoL NZ&nKi%D=a9|4)cuU3UNg literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551586.manifest b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/18446744073709551586.manifest deleted file mode 100644 index e5cef20ebaa1200889ce6ef9f3f80938b5d7f6e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1968 zcmX@c$iTqW$H*nbCD38GL}<%$&(f(p{m-;N=6x4pWRw!hPs&P7E{QLxEJ%${$}GuE zEKrcq60tI1U;t_a0!9V_ASuis!eFGxC9j{8n3tTYSCW{Nld9(gR_>XXl3L-CT9TNV zQ*6QDu<6d`P0c1jvRo1n)8Y#f(^KPf^7FGx3-rLc1=g@}%>+9rI^+B4%hw!y=ReV3 zt?Q#>C&b7g#Se8*W=ecgNl|JlPS+@K$)UO?1gILjW5l=wF^||aG;%J-o(tnz#$`~Vr-demXel~rkiG%Y^rOLWSFXJVPKJ{ zo0@8DVw7fXVr*fYwu`NeeKgjGL#zw+fRm$U^{-sBKYqSz3p>{7o?br(SKbxZ5<=u% z258tB>N=Tan1JPPs1PT-SX1~91B_a1 z#U(|VdFd8RL55s9;KEV2I5{IVH_@szFEuYMzbH91MYo_RGdHoQQa3xb(yAc8II|=( zKTn9!Ac2ufMTi?}EU5UFV8m)Pm$DE?PJVJCNSg!`79|FZ7EB;Txxg|UR1!)s<5Hw5 z#DiP{O0Xa+WC4|gK(iRRq>+3p!K%Ol^Crk5p->KCA%89lLqh`t149r5vOpLFfMkk! zim`=-iJ^f-l8LF2iLtSvajJ!dp{b!Uq<|J;Wng4tW|aa{EDB5-j0QV}6!?I~ihzs- z8U;oW;~3#l!LM zz~(D#0vC2&`N|j}lB8{9p-W5|tXEQzm&M1$3XWGHR&zZAJ%cJ97@yfl&u|6fDdq-N Oa5W1gm>7H<{hR?GuUkq0 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json index c6ccfd69ce22cd..6e754b99aaa8aa 100644 --- a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json +++ b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/_versions/latest_version_hint.json @@ -1 +1 @@ -{"version":29} \ No newline at end of file +{"version":34} \ No newline at end of file diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/10010110000000010010101183fee04e8eb3b452a26bcf00a5.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/__manifest/data/10010110000000010010101183fee04e8eb3b452a26bcf00a5.lance new file mode 100644 index 0000000000000000000000000000000000000000..4cbe1bdceff686b0dc391e937fb49e13890e0fef GIT binary patch literal 2489 zcmchYO>fjj7{|w6XB#FhEYm}`MO9ZNloFb)?R~K~st_nefCQ)tqCJ3Q#`bJ**|j%o zZ<=u6rRt@sKuB=Nkz3VoP)_6yXZRF7fjFl9d+lv@6O$z%F{_`)Gtb*RJM)Z7xzq2| z2)QS1pnXpWIR_TNBDfCjfIq=w@Ep7VA4!Bvg9f+=Ja7Zt1H9F;qScNF-Jt6wkLl&m z@|K%cv&AE;S<(BF!DMG?+f=xH>Z%|9l5O9Yyv`!S`Ct*26NH{GGS;$##0{bmgTz_A znlY)>v;6c8dgi9S?}`vEWTNRh8aUGh2_4H8R-$GuYpF|9=9P z0S8TR4Rk;c#OLBB_znCH?t=SZ4Mf~-37@w`*x`=okKXx_z3R=`E6V2k+q@M>i4^3G z4MK2$cW7~aTu50$AJ@iT;3@bA?1gy+{0Nr81Mmnu124fod8j_hE$%y_X4tyZ5Or(w zNDNhqU2z(YsH&sVZmNK1Oqa|sW(PtBSoavRMl+SMutk_!?Z-UV^SZ7L638r13Fl^~-?%fmlWi;;XHWl- z=|#3Duzwa#WO;7s-$9dR@*(aPf6Cw$t_6yt_Jk zO{~s!a4$w);4?|RBGG9!mAZ{D^`eyNnjU16t-#^2Sy9eVQ6*E3mD;!vTpsaa?sZCs z*oQU`g_Q&ii^{N~((=Lxz9f2JDBI#hNg1RW2Pzck5q6j!qF;R5&$ldmCvfo}gys35 z)oc4F(=n4NVJE3fjE~PRbgldYUleEgYS4?=$8`T^lPpgaDo>Vl z3ClUXtB9wla!XlP5c8q*zG9w3%=~&DG5=N&^BDV%&W+3SQ~C*ub1V#Jo7-)+WrUky zU(>I~g_~fx87a4$t!p5usr4x)mr4k7lpUdm#|6nVWk!~i!Uwpm(Yi@Teoe9ASr{878sU(J zS>xdWa_mGwh0O;Tk1#TNOwMM>;CAC;Il$P!B*es|0OvC`Fa!AsK>ji229^VpO;}Cp zColuu0R)8*ih+>@!YGFFOVIewq5Np5yb~*gkp|^AFhLjsj1U@T{tBqNA5a=34PFyF&|)Vm|VhYQXjw!v=#^o zAr!+wD8CrWH(`M=VEpA!el%2`i50>~gYq4iAPfOU2n{no1FCKZlmHV%6u{3P3GIQZats70qJoyvlm_S1PE{eQs-mJFAOr(K!kq0(Ye?)6yRCv4 z!T>^oq5lEmPw2$jff@QIurRT7WCGsBPL-q%RY2mRpY6N%b?5hpd4lZMxo^EfJ^irAndMTS} zN?ME6XNQ)&!%ORqw-s&Ql=QNQaNgR4Zn~cBu!z0 zlQn05dFgNOT>GokzfHTv5E?;yP{9a6A@&%p_p=0Yjt%hI{s4Rez5#JCp8{3^2Y3hU z0H1+_`c9RpW3s$zWXvKf=n*B5%@|p;XtJErDGjQ!l(%w~a< zv{s*f%IY&!_&)iz>kvVCEZ{M6Jao%Ht_dO1HNHd!%C1SdSwf6ZQ3OroN)>2qi~2Mn z^->xqCk*N_JqYS0G4WFFCC(jO$IF_+E zFq#oyJBxiqyou#qsUbtmZQ*~#T!EOeMjT@PkRaxDGK*)9I_DXDn(#9Yx1Uz(_p~Vp zmn2v4<)gyIh?Eqheo|M&as%&TB94qekSQ{WCyojdCvs908VDXr;k*$G9){sV%}2}sPN*jg2J2kRRYhp*<+;`Q-yAihWdHyG diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/metadata.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/metadata.lance new file mode 100644 index 0000000000000000000000000000000000000000..af3dad059c929c1d316f0b9b73bd7c2b1c0e80ca GIT binary patch literal 734 zcmbtSL5mYH6wahlN=|J&xCmkiEb35dx4SH5PRhElheh_jNXX8-nFcdSon*GHBKRvj zc=EFTO!c)hEv#MyA532IzVCZ^`IsTO_^%+OPkv(b$Yw`KGQkDoUz@V;9rm0LGB6Mz zlX<0z5^V6pU%QCn?v1vpTF+=aWPIU#(6*34X(LmYLCRUKSCAb~QL6%wdu|J({Tz!+9p)0p+|D49P}MLJrCK$ur2$)nxkyeXsCP@KWQzTF zJRhJGNv@1XQ3a*D1?huZ$fc_?zemygXbcv8$M$}F=1g7M^Y+XsBm51cd%$RKIE|uNbP*lW<}dotB=V)rcTuz1;a^+c Qk2Y`M%|~&)eEs(9H-_riumAu6 literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_docs.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_docs.lance new file mode 100644 index 0000000000000000000000000000000000000000..5dce8a68b3a07db6cb1c449017c0a581cf6f537c GIT binary patch literal 637 zcmXqD@Sq4VFi0@`0|EvpV1&|4P@0h83KZMV1Gb+TXdep@gJ_U@2-yph=W^we6=I7o z$}i7MQTPu7j9P4^nRz8Y&Ql2i3k^OEyZGV{`l^_=r_N^|pEp?o1GE(Re7E+z&CAtojT31%e|AtNpWE?IPK zMuvI;iRr06iIw@KB|`jMyizP&OdJkIjEokH9wb@j#KqFUcz_XTodl`YDL}3BgvM$v kG*Y)g`T0;j1C;Lx<-^pgLQ{b&lm;mSF`0lGd>s9p0sIu5NdN!< literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_invert.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_invert.lance new file mode 100644 index 0000000000000000000000000000000000000000..2d4ccf3f952ecbc9ecaf1162e681ee4ee51b489c GIT binary patch literal 1851 zcmbu9v1=1i9LL{#cbCK;wU$S~cs6)cMD0*xbSR=pnksZCQo+e3p7l%*E_ae#z=BA0 za&QqG{1ZAjbm-vV5YR!v!9mcWgM+ApgM&j6iN0U*J2~2zqx2UZpZDJH_ulvS`z0?+ zd|^*!Fknwvug6*R>GiF^26mBnop_h{kobi7`|Ga~PI+uRdOZGbPsH{yW|Z%!xl)eq z+4%CH9Gx9jsG;tP+pw5Xdj&Sio9tsOtGd_gVf|PW4$L3(#{Hxl_O3t1G?fANNK*s* zhPqq`M52C0BxN|-pJgZ57U`pQ^j!^`qi;ar`Iq?OtOMFFqO?*pyM|@BG zNZcfTB7P=*BYr3TApRt75ix%>0MEtmM%L{Bg zZ^?AcbNqU^lGEY=skZBfXNvuZeVBk7x`Ch5<1tYVS<1n%^g&)qox>#UrYvJ;ZW9Ka zl*i#1969CL{<1UU_{+gfRby*rKJeNN{}QdG04A@1&MF-tb+OJTa>mr0EW-uJZ$DZ* zJ+oxjoke>!XoqqVOwd7OCd`aX^=Fl$;9{0n_`~mFC zpp+z@hr>Jitf_yfextLSIzAA_?hU}M(VGZp{f5@7w63NGll!!0bUu3W(S0u|PD>90 P@u^~{P!D=>?(+O!W{;8} literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_tokens.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_indices/998a5a77-b8d4-4318-abad-3fc2341fc194/part_0_tokens.lance new file mode 100644 index 0000000000000000000000000000000000000000..e730e8ed9d19fb36033e9925ef77745ad7ae87e5 GIT binary patch literal 1026 zcmaFB;6V{!U?^bt2Lu8P5Re0Ij3tq(iR`T>=U; zojQG#TcGji`9sG~voJ6+F*9=Viz}4oWTqqvva&)AaEH?MQzVR$9V|ex`&q#52iXJi z2eR43uqn5n18lDf)PKa-iL8dJjgd=NNFcr>KRY!qKCQSUKB=-KwOHXl3@~c(Wo zFUgQ#!cc8yV!q2zlgLyE_ zz*-oir6~#)Ym+ie&@kn+W-5*`;|5b_v%7yI!4VFD;#`O!Uk`0SXkFN(z!Y;OrNDhI zVt-4OnmMjkA{Z=36>veEMc*-omuOHmP6!q_yn56SQ9U%Ixc SOGRM?vs`zsY}!7ge=jc{w@3c~ literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/1-0a6db125-38e9-4ae5-94d5-5972747763df.txn b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/1-0a6db125-38e9-4ae5-94d5-5972747763df.txn new file mode 100644 index 0000000000000000000000000000000000000000..2611a3256d7a42d55d51fcc789db1db847ec9858 GIT binary patch literal 284 zcmd;J6jCurG)qY`G&0pSwn(+qHAzf0)wMK9G1WD-G&eFgF*i3ePD$Iv_JNV>EhCo@ zm%z-fsPY|GoF!JTwLfGce{hNrBcqf+QhrKhd|F9yd_iJSNoHb>0;86Yl>q|-BLf5o zfGHyyp+@mm(-HP%$#B&SuVEX%-n*UR3T0! z*Sz$c%;F3s2}T7C4Mq><0A>qDheLNRZEH0N65|pC*%ogQpOT+ktOqt%;4e#%Fc)7g n(5RHel0>NBJ=P!zE@7zRlKkw{Jecw(4kVdOuxp@tcJl!MbS6}# literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/2-315f4e75-d073-4bd2-a75d-f04c8347628b.txn b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_transactions/2-315f4e75-d073-4bd2-a75d-f04c8347628b.txn new file mode 100644 index 0000000000000000000000000000000000000000..b87214d155d2c644f45d79701bec61c89b0f93ed GIT binary patch literal 124 zcmd;J5>hcXG)*%}H8<5wF)%mQHAzY_(oHlsP0>v=FiEyBHZeCdvPfFQ7Rco< zdb+0WzdUz9PQQJM09fn=8QU92q8M+}T*NfdzyPgcNdjeFh2G3s7vb{Rgr*@k1Pu~y ztf%*QE;w6H8Q;Cc1&xyeEx~pXZ*3y!B_d&_I zBcn66=|qIPj?S=a>m*>7PF;uT5hkH$6L)BN!SnVeypA@Yhp|~MY?i>)zCvZaA*0+Q zR^$epo33BhZOxm~h}jVtmhW^NllY-y znP$KpLjFctL7N@$pLI|bEUnIWK7W}`+r4-DkKZddK`^lNfB6?f?520Rur(VF)N&1M Z;QRB6l51XiPG)h2k_4lIh6bYta{#jiqr;&)m$tQ<1c`A8f^3U7h)>B+ zF4hB^EAW>kNSKQ+7id&UVo4%Y@E&WB1eY*WaY=r5Y935^69GezKLxkBbUFB zHo`If06%UNhY@}znf|21qFv=l-iNVLw&lv!N CGopL| literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/latest_version_hint.json b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/latest_version_hint.json new file mode 100644 index 00000000000000..f93d3984472d99 --- /dev/null +++ b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/_versions/latest_version_hint.json @@ -0,0 +1 @@ +{"version":3} \ No newline at end of file diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/data/00101001111000100000100086dae842f7bee179e75e92939e.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/data/00101001111000100000100086dae842f7bee179e75e92939e.lance new file mode 100644 index 0000000000000000000000000000000000000000..9588683b1be0f9ad2f5e799e7b63da77d213d387 GIT binary patch literal 991 zcmbu6&q@O^5XO^qEoD%w5n(A-qbOPrRZA&*Tq#NqReJQM>n7HPHYwXJwYR>4Z{W?> z=##10ZdyeAQ{4}SnIV(>J|+w`QVF3vdewSBi2{=see^jszcuAAYi>!$rH+m#eZjn5 zPe?8}^X?-NvOw35fo%7MuOw$b&MazP&$+4nv$_8b?R`tfKu*mHJ}I}N_-1L+HqAn2RQbKH->{e+7PN4YtTIICd^4g;5jnp8btFThAfaKf? z#tv@$33dV27*DXqU2B{}Yf$|Sw+?HC9L++EWdB1>$T*{shiQ;|ME|o^H%aKp^nKG? enWUKY6P)zioX_qL&D@L0@mO3Egi-VCs{RgYnFiwk literal 0 HcmV?d00001 diff --git a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/data/001100101100101101000001aafd3f464c855149c5211be544.lance b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/lance/d9b832a2_doris$full_text_search_partial/data/001100101100101101000001aafd3f464c855149c5211be544.lance new file mode 100644 index 0000000000000000000000000000000000000000..d561f04f549448722923d763fe062a0eb4211bab GIT binary patch literal 991 zcmbu6&rZTX5XN`6m5@n{xE^eXXf@)$10o@$M+|B>5W^EFwOvaS*e0b7a^n?z3SXm7 zrVey5N-#wGC6jL_GqbT+n z$8%vYe)L`QV9);+Wr&l4)iHaDB}8vvv>pQ>u@`;UCi?w%#h~)kO`Y=h^%Ho9y9#Ms9|d4 jRpld<>soqP properties) common, fieldId, searchRequest, SCORE_COLUMN, "full-text search"); } - private static Field findStringField(LanceTableMetadata metadata, String column) + @VisibleForTesting + static Field findStringField(LanceTableMetadata metadata, String column) throws AnalysisException { Field match = null; for (Field field : metadata.getSchema().getFields()) { @@ -98,7 +100,8 @@ private static Field findStringField(LanceTableMetadata metadata, String column) return match; } - private static TFtsCoverageMode parseCoverageMode(String value) throws AnalysisException { + @VisibleForTesting + static TFtsCoverageMode parseCoverageMode(String value) throws AnalysisException { switch (value.trim().toLowerCase(Locale.ROOT)) { case "strict": return TFtsCoverageMode.STRICT; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java index e38f0120270748..c71c839fbc8bee 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java @@ -30,6 +30,8 @@ import org.apache.doris.thrift.TExternalSearchQuery; import org.apache.doris.thrift.TExternalSearchRequest; import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFullTextSearchParams; import org.apache.doris.thrift.TVectorMetric; import org.apache.doris.thrift.TVectorSearchOptions; import org.apache.doris.thrift.TVectorSearchParams; @@ -255,6 +257,86 @@ public void testExternalSearchUseIndexFalseKeepsFragmentSplits() throws Exceptio assertSplit(splits.get(1), 2, 8, 88); } + @Test + public void testFullTextSearchUsesOneSplitPerCommittedIndexSegment() throws Exception { + UUID firstSegment = UUID.fromString("11111111-2222-3333-4444-555555555555"); + UUID secondSegment = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + LanceTableMetadata metadata = LanceTableMetadata.withIndexSegments( + "s3://bucket/table.lance", + 42, + fullTextSchema(), + Arrays.asList( + new LanceFragmentInfo(1, 8, 8), + new LanceFragmentInfo(2, 7, 7), + new LanceFragmentInfo(3, 6, 6)), + Collections.singletonMap("body", 7), + Arrays.asList( + new LanceIndexSegmentInfo(firstSegment, "body_fts", + Collections.singletonList(7), Arrays.asList(1L, 2L), + IndexType.INVERTED, null), + new LanceIndexSegmentInfo(secondSegment, "body_fts", + Collections.singletonList(7), Collections.singletonList(3L), + IndexType.INVERTED, null)), + Collections.emptyMap()); + TExternalSearchRequest request = fullTextSearchRequest( + 5, 2, TFtsCoverageMode.STRICT); + LanceScanNode node = newSearchNode(metadata, request); + + List splits = node.getSplits(8); + + Assert.assertEquals(2, splits.size()); + assertIndexSplit(splits.get(0), firstSegment, Arrays.asList(1L, 2L), 15, 100); + assertIndexSplit(splits.get(1), secondSegment, Collections.singletonList(3L), 15, 40); + + TFileRangeDesc range = new TFileRangeDesc(); + node.setScanParams(range, splits.get(1)); + Assert.assertEquals(Collections.singletonList(3L), range.getTableFormatParams() + .getLanceParams().getFragmentIds()); + ByteBuffer encodedUuid = range.getTableFormatParams().getLanceParams() + .getIndexSegmentUuids().get(0).duplicate(); + Assert.assertEquals(secondSegment.getMostSignificantBits(), encodedUuid.getLong()); + Assert.assertEquals(secondSegment.getLeastSignificantBits(), encodedUuid.getLong()); + + TExternalSearchRequest splitRequest = node.createSplitSearchRequest(); + Assert.assertEquals(7, + splitRequest.getSearchQuery().getFullTextSearch().getTopK()); + Assert.assertEquals(0, + splitRequest.getSearchQuery().getFullTextSearch().getOffset()); + Assert.assertEquals(5, request.getSearchQuery().getFullTextSearch().getTopK()); + Assert.assertEquals(2, request.getSearchQuery().getFullTextSearch().getOffset()); + } + + @Test + public void testFullTextSearchCoverageModesHandleUnindexedFragments() throws Exception { + UUID segment = UUID.fromString("11111111-2222-3333-4444-555555555555"); + LanceTableMetadata metadata = LanceTableMetadata.withIndexSegments( + "s3://bucket/table.lance", + 42, + fullTextSchema(), + Arrays.asList( + new LanceFragmentInfo(1, 8, 8), + new LanceFragmentInfo(2, 7, 7), + new LanceFragmentInfo(3, 6, 6)), + Collections.singletonMap("body", 7), + Collections.singletonList( + new LanceIndexSegmentInfo(segment, "body_fts", + Collections.singletonList(7), Arrays.asList(1L, 2L), + IndexType.INVERTED, null)), + Collections.emptyMap()); + + LanceScanNode strictNode = newSearchNode(metadata, + fullTextSearchRequest(10, 0, TFtsCoverageMode.STRICT)); + UserException strictFailure = Assert.assertThrows(UserException.class, + () -> strictNode.getSplits(2)); + Assert.assertTrue(strictFailure.getMessage().contains("1 unindexed fragments")); + + LanceScanNode indexOnlyNode = newSearchNode(metadata, + fullTextSearchRequest(10, 0, TFtsCoverageMode.INDEX_ONLY)); + List indexOnlySplits = indexOnlyNode.getSplits(2); + Assert.assertEquals(1, indexOnlySplits.size()); + assertIndexSplit(indexOnlySplits.get(0), segment, Arrays.asList(1L, 2L), 15, 100); + } + @Test public void testExternalSearchFallsBackToFragmentSplitsForMetricMismatch() throws Exception { LanceTableMetadata metadata = LanceTableMetadata.withIndexSegments( @@ -338,11 +420,13 @@ private static LanceScanNode newNode() { private static LanceScanNode newSearchNode( LanceTableMetadata metadata, TExternalSearchRequest request) { - String vectorColumn = request.getSearchQuery().getVectorSearch().getColumn(); - int vectorFieldId = metadata.getLanceFieldId(vectorColumn).orElse(-1); + String searchColumn = request.getSearchQuery().isSetVectorSearch() + ? request.getSearchQuery().getVectorSearch().getColumn() + : request.getSearchQuery().getFullTextSearch().getColumn(); + int searchFieldId = metadata.getLanceFieldId(searchColumn).orElse(-1); return LanceScanNode.forExternalSearch( new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), null, - metadata, vectorFieldId, request, new SessionVariable()); + metadata, searchFieldId, request, new SessionVariable()); } private static void setMetadata(LanceScanNode node, LanceTableMetadata metadata) throws Exception { @@ -373,6 +457,11 @@ private static Schema vectorSchema() { Field.nullable("vector", ArrowType.Utf8.INSTANCE))); } + private static Schema fullTextSchema() { + return new Schema(Collections.singletonList( + Field.nullable("body", ArrowType.Utf8.INSTANCE))); + } + private static void assertInvalidSplit(Runnable action, String expectedMessage) { try { action.run(); @@ -390,4 +479,16 @@ private static TExternalSearchRequest vectorSearchRequest(long topK, long offset return new TExternalSearchRequest() .setSearchQuery(TExternalSearchQuery.vector_search(vector)); } + + private static TExternalSearchRequest fullTextSearchRequest( + long topK, long offset, TFtsCoverageMode coverageMode) { + TFullTextSearchParams fullText = new TFullTextSearchParams() + .setColumn("body") + .setQuery("lance") + .setTopK(topK) + .setOffset(offset) + .setCoverageMode(coverageMode); + return new TExternalSearchRequest() + .setSearchQuery(TExternalSearchQuery.full_text_search(fullText)); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java new file mode 100644 index 00000000000000..f489a3ede2db6d --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java @@ -0,0 +1,105 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.tablefunction; + +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.Type; +import org.apache.doris.common.AnalysisException; +import org.apache.doris.datasource.lance.LanceTableMetadata; +import org.apache.doris.thrift.TFtsCoverageMode; + +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class FullTextSearchTableValuedFunctionTest { + @Test + public void testParseCoverageMode() throws Exception { + Assert.assertEquals(TFtsCoverageMode.STRICT, + FullTextSearchTableValuedFunction.parseCoverageMode(" STRICT ")); + Assert.assertEquals(TFtsCoverageMode.INDEX_ONLY, + FullTextSearchTableValuedFunction.parseCoverageMode("index_only")); + Assert.assertEquals(TFtsCoverageMode.INDEX_ONLY, + FullTextSearchTableValuedFunction.parseCoverageMode("INDEX-ONLY")); + + AnalysisException invalid = Assert.assertThrows(AnalysisException.class, + () -> FullTextSearchTableValuedFunction.parseCoverageMode("flat")); + Assert.assertTrue(invalid.getMessage().contains("strict or index_only")); + } + + @Test + public void testResolveStringColumnCaseInsensitively() throws Exception { + Field rowId = Field.notNullable("row_id", new ArrowType.Int(64, true)); + Field body = Field.nullable("Body", ArrowType.LargeUtf8.INSTANCE); + LanceTableMetadata metadata = metadata(rowId, body); + + Assert.assertSame(body, + FullTextSearchTableValuedFunction.findStringField(metadata, "body")); + } + + @Test + public void testRejectMissingNonStringAndAmbiguousColumns() { + LanceTableMetadata metadata = metadata( + Field.notNullable("row_id", new ArrowType.Int(64, true)), + Field.nullable("body", ArrowType.Utf8.INSTANCE)); + AnalysisException missing = Assert.assertThrows(AnalysisException.class, + () -> FullTextSearchTableValuedFunction.findStringField(metadata, "title")); + Assert.assertTrue(missing.getMessage().contains("does not exist")); + + LanceTableMetadata nonString = metadata( + Field.notNullable("body", new ArrowType.Int(64, true))); + AnalysisException wrongType = Assert.assertThrows(AnalysisException.class, + () -> FullTextSearchTableValuedFunction.findStringField(nonString, "body")); + Assert.assertTrue(wrongType.getMessage().contains("must be STRING")); + + LanceTableMetadata ambiguous = metadata( + Field.nullable("Body", ArrowType.Utf8.INSTANCE), + Field.nullable("body", ArrowType.Utf8.INSTANCE)); + AnalysisException duplicate = Assert.assertThrows(AnalysisException.class, + () -> FullTextSearchTableValuedFunction.findStringField(ambiguous, "BODY")); + Assert.assertTrue(duplicate.getMessage().contains("ambiguous")); + } + + @Test + public void testBuildOutputColumnsAddsNullableFloatScore() throws Exception { + LanceTableMetadata metadata = metadata( + Field.notNullable("row_id", new ArrowType.Int(64, true)), + Field.nullable("body", ArrowType.Utf8.INSTANCE)); + + List columns = FullTextSearchTableValuedFunction.buildOutputColumns( + metadata, FullTextSearchTableValuedFunction.SCORE_COLUMN, "full-text search"); + + Assert.assertEquals(3, columns.size()); + Column score = columns.get(2); + Assert.assertEquals(FullTextSearchTableValuedFunction.SCORE_COLUMN, score.getName()); + Assert.assertEquals(Type.FLOAT, score.getType()); + Assert.assertTrue(score.isAllowNull()); + } + + private static LanceTableMetadata metadata(Field... fields) { + return LanceTableMetadata.withoutIndexSegments( + "s3://bucket/table.lance", 42, new Schema(Arrays.asList(fields)), + Collections.emptyList(), Collections.emptyMap()); + } +} diff --git a/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out b/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out new file mode 100644 index 00000000000000..97cef25707580a --- /dev/null +++ b/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out @@ -0,0 +1,18 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !fts_ranked -- +3 1.0073528 +2 0.9261318 +1 0.74574697 +7 0.6000379 + +-- !fts_offset -- +2 +1 + +-- !fts_filter -- +7 + +-- !fts_index_only -- +101 +102 + diff --git a/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy b/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy new file mode 100644 index 00000000000000..6031c666464060 --- /dev/null +++ b/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy @@ -0,0 +1,120 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_lance_full_text_search", "p0,external") { + String enabled = context.config.otherConfigs.get("enableIcebergTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable Lance full-text search test because the Iceberg MinIO environment is disabled.") + return + } + + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String minioPort = context.config.otherConfigs.get("iceberg_minio_port") + String catalogName = "test_lance_full_text_search" + String fullTable = "${catalogName}.doris.full_text_search" + String partialTable = "${catalogName}.doris.full_text_search_partial" + + def search = { String table, String query, String topK, String offset = "0", + String coverageMode = "strict", String extra = "" -> + """full_text_search( + "table"="${table}", + "column"="body", + "query"="${query}", + "top_k"="${topK}", + "offset"="${offset}", + "coverage_mode"="${coverageMode}"${extra})""" + } + + sql "DROP CATALOG IF EXISTS `${catalogName}`" + try { + sql """ + CREATE CATALOG `${catalogName}` PROPERTIES ( + "type" = "lance", + "lance.catalog.type" = "filesystem", + "warehouse" = "s3://warehouse/lance", + "s3.endpoint" = "http://${externalEnvIp}:${minioPort}", + "s3.access_key" = "admin", + "s3.secret_key" = "password", + "s3.region" = "us-east-1", + "use_path_style" = "true" + ) + """ + sql "SET enable_file_scanner_v2 = true" + + // The full corpus is indexed across multiple fragments. The plan must use FTS index + // segment splits, and strict coverage must see no unindexed fragment. + explain { + sql """SELECT row_id, _score + FROM ${search(fullTable, "lance", "4")} + ORDER BY _score DESC, row_id""" + contains "externalSearchType=FULL_TEXT" + contains "lanceFullTextColumn=body" + contains "lanceFtsCoverageMode=STRICT" + contains "lanceSearchUnindexedFragments=0" + contains "lanceSearchIndexSegments=" + } + + qt_fts_ranked """ + SELECT row_id, _score + FROM ${search(fullTable, "lance", "4")} + ORDER BY _score DESC, row_id + """ + + // OFFSET belongs to the snapshot-wide TopN. It must not be applied independently by + // every physical FTS index split. + qt_fts_offset """ + SELECT row_id + FROM ${search(fullTable, "lance", "2", "1")} + ORDER BY _score DESC, row_id + """ + + // The TVF filter runs inside Lance before FTS candidates are selected. + qt_fts_filter """ + SELECT row_id + FROM ${search(fullTable, "lance", "4", "0", "strict", + ', "filter"="category = \'storage\'"')} + ORDER BY _score DESC, row_id + """ + + // The second table has a committed FTS index plus one fragment containing two rows + // appended afterwards. STRICT is the safe default and rejects the incomplete snapshot. + test { + sql """SELECT row_id + FROM ${search(partialTable, "lance", "10")} + ORDER BY _score DESC, row_id""" + exception "unindexed fragments" + } + + // INDEX_ONLY searches exactly the fragments covered by the selected FTS segment. The + // two appended rows also contain "lance" but must not leak into the result. + explain { + sql """SELECT row_id + FROM ${search(partialTable, "lance", "10", "0", "index_only")} + ORDER BY _score DESC, row_id""" + contains "externalSearchType=FULL_TEXT" + contains "lanceFtsCoverageMode=INDEX_ONLY" + contains "lanceSearchUnindexedFragments=1" + } + qt_fts_index_only """ + SELECT row_id + FROM ${search(partialTable, "lance", "10", "0", "index_only")} + ORDER BY _score DESC, row_id + """ + } finally { + // sql "DROP CATALOG IF EXISTS `${catalogName}`" + } +} diff --git a/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch b/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch index dd12ca19ab7df6..7351f448135d23 100644 --- a/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch +++ b/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch @@ -1298,10 +1298,10 @@ index f8158d9..0fe31c4 100644 // A non-zero sentinel proves the arm returns `$errval` verbatim. diff --git a/src/fts_query.rs b/src/fts_query.rs new file mode 100644 -index 0000000..cd194c7 +index 0000000..e85ed99 --- /dev/null +++ b/src/fts_query.rs -@@ -0,0 +1,324 @@ +@@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + @@ -1358,6 +1358,7 @@ index 0000000..cd194c7 + pub(crate) query: FullTextSearchQuery, + pub(crate) segments: Vec, + pub(crate) scorer: Arc, ++ pub(crate) has_unindexed_fragments: bool, +} + +impl FtsQueryContextInner { @@ -1520,6 +1521,7 @@ index 0000000..cd194c7 + query, + segments, + scorer, ++ has_unindexed_fragments: !unindexed_fragment_ids.is_empty(), + }) +} + @@ -1855,10 +1857,10 @@ index ed9cfe1..4d54641 100644 pub use index_model::*; pub use index_segment::*; diff --git a/src/scanner.rs b/src/scanner.rs -index ef9d290..7110111 100644 +index ef9d290..f60f0c5 100644 --- a/src/scanner.rs +++ b/src/scanner.rs -@@ -6,28 +6,37 @@ +@@ -6,28 +6,34 @@ use std::ffi::{c_char, c_void}; use std::pin::Pin; use std::ptr; @@ -1868,8 +1870,7 @@ index ef9d290..7110111 100644 use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; use arrow::ffi_stream::FFI_ArrowArrayStream; --use arrow_schema::SchemaRef; -+use arrow_schema::{Schema as ArrowSchema, SchemaRef}; + use arrow_schema::SchemaRef; +use datafusion::physical_plan::ExecutionPlan; use futures::{FutureExt, Stream, StreamExt}; use lance::Dataset; @@ -1878,9 +1879,6 @@ index ef9d290..7110111 100644 }; +use lance::io::exec::fts::MatchQueryExec; use lance_core::Result; -+use lance_datafusion::exec::{LanceExecutionOptions, get_session_context}; -+use lance_datafusion::planner::Planner; -+use lance_datafusion::substrait::parse_substrait; use lance_index::scalar::FullTextSearchQuery; use lance_io::stream::RecordBatchStream; +use lance_table::format::IndexMetadata; @@ -1900,7 +1898,7 @@ index ef9d290..7110111 100644 }; use crate::helpers; use crate::runtime::{RT, block_on}; -@@ -50,6 +59,7 @@ pub struct LanceScanner { +@@ -50,6 +56,7 @@ pub struct LanceScanner { columns: Option>, filter: Option, substrait_filter: Option>, @@ -1908,7 +1906,7 @@ index ef9d290..7110111 100644 limit: Option, offset: Option, batch_size: Option, -@@ -64,13 +74,20 @@ pub struct LanceScanner { +@@ -64,13 +71,20 @@ pub struct LanceScanner { use_index: Option, prefilter: bool, fts_query: Option, @@ -1930,7 +1928,7 @@ index ef9d290..7110111 100644 scan_statistics_callback: Option, scan_started: AtomicBool, // Materialized on first iteration call -@@ -111,6 +128,7 @@ impl LanceScanner { +@@ -111,6 +125,7 @@ impl LanceScanner { columns: None, filter: None, substrait_filter: None, @@ -1938,7 +1936,7 @@ index ef9d290..7110111 100644 limit: None, offset: None, batch_size: None, -@@ -125,7 +143,10 @@ impl LanceScanner { +@@ -125,7 +140,10 @@ impl LanceScanner { use_index: None, prefilter: false, fts_query: None, @@ -1949,7 +1947,7 @@ index ef9d290..7110111 100644 scan_statistics_callback: None, scan_started: AtomicBool::new(false), stream: None, -@@ -161,86 +182,58 @@ impl LanceScanner { +@@ -161,86 +179,91 @@ impl LanceScanner { Ok(()) } @@ -1980,13 +1978,35 @@ index ef9d290..7110111 100644 - return Err(lance_core::Error::invalid_input_source( - "index_segments requires nearest() to be configured".into(), - )); -- } ++ /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the ++ /// selected committed segments. This is deliberately separate from ++ /// `fast_search`: that option is scanner-wide, changes unrelated scalar ++ /// index fallback behavior, and also forces `_rowid` into the output. ++ fn apply_prepared_fts_fragment_filter( ++ &self, ++ scanner: &mut lance::dataset::scanner::Scanner, ++ context: &FtsQueryContextInner, ++ segments: &[IndexMetadata], ++ ) -> Result<()> { ++ if !context.has_unindexed_fragments { ++ return Ok(()); + } - // Lance validates fragment-scoped nearest searches when nearest() is - // configured. Such searches are supported when the fragment scan is - // the input to a prefilter, so this flag must be set first. - if self.prefilter { - scanner.prefilter(true); -- } ++ ++ let mut selected_fragment_ids = std::collections::HashSet::new(); ++ for segment in segments { ++ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { ++ lance_core::Error::internal(format!( ++ "prepared FTS segment {} lost its validated fragment coverage", ++ segment.uuid ++ )) ++ })?; ++ selected_fragment_ids.extend(fragment_bitmap.iter()); + } - if let Some(n) = &self.nearest { - scanner.nearest(&n.column, n.query.as_ref(), n.k as usize)?; - if let Some(np) = self.nprobes { @@ -2006,44 +2026,54 @@ index ef9d290..7110111 100644 - } - if let Some(segments) = &self.index_segments { - scanner.with_index_segments(segments.clone())?; +- } ++ ++ let selected_fragments = self ++ .dataset ++ .get_fragments() ++ .into_iter() ++ .filter(|fragment| { ++ u32::try_from(fragment.id()) ++ .is_ok_and(|fragment_id| selected_fragment_ids.contains(&fragment_id)) ++ }) ++ .map(|fragment| fragment.metadata().clone()) ++ .collect(); ++ scanner.with_fragments(selected_fragments); ++ Ok(()) ++ } ++ + fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { -+ if self.additional_sql_filters.is_empty() { -+ if let Some(substrait) = &self.substrait_filter { -+ scanner.filter_substrait(substrait)?; -+ } else if let Some(sql) = &self.filter { -+ scanner.filter(sql)?; - } -+ return Ok(()); ++ if let Some(substrait) = &self.substrait_filter { ++ scanner.filter_substrait(substrait)?; ++ } else if let Some(sql) = &self.filter { ++ scanner.filter(sql)?; } - if let Some(fts) = &self.fts_query { - scanner.full_text_search(fts.clone())?; -- } ++ ++ if self.additional_sql_filters.is_empty() { ++ return Ok(()); + } - if let Some(callback) = &self.scan_statistics_callback { - scanner.scan_stats_callback(callback.clone()); + -+ let schema = Arc::new(ArrowSchema::from(self.dataset.schema())); -+ let planner = Planner::new(Arc::clone(&schema)); -+ let mut combined = if let Some(substrait) = &self.substrait_filter { -+ let context = get_session_context(&LanceExecutionOptions::default()); -+ Some( -+ parse_substrait(substrait, schema, &context.state()) -+ .now_or_never() -+ .expect("Substrait filter parsing must complete synchronously")?, -+ ) -+ } else if let Some(sql) = &self.filter { -+ Some(planner.parse_filter(sql)?) -+ } else { -+ None -+ }; ++ // Let Lance resolve every SQL expression against the scanner's full ++ // filterable schema. Besides stored columns, this includes metadata ++ // columns and query-generated columns such as _distance and _score. ++ let mut combined = scanner.get_expr_filter()?; + for sql in &self.additional_sql_filters { -+ let sql = planner.parse_filter(sql)?; ++ let mut additional_scanner = scanner.clone(); ++ additional_scanner.filter(sql)?; ++ let additional = additional_scanner ++ .get_expr_filter()? ++ .expect("additional SQL filter exists"); + combined = Some(match combined { -+ Some(existing) => existing.and(sql), -+ None => sql, ++ Some(existing) => existing.and(additional), ++ None => additional, + }); } - let stream = block_on(scanner.try_into_stream())?; -+ scanner.filter_expr(planner.optimize_expr(combined.expect("additional filter exists"))?); ++ scanner.filter_expr(combined.expect("additional SQL filter exists")); + Ok(()) + } + @@ -2070,11 +2100,10 @@ index ef9d290..7110111 100644 - } else if let Some(filter) = &self.filter { - scanner.filter(filter)?; - } -+ self.apply_filter(&mut scanner)?; if self.limit.is_some() || self.offset.is_some() { scanner.limit(self.limit, self.offset)?; } -@@ -256,6 +249,16 @@ impl LanceScanner { +@@ -256,6 +279,16 @@ impl LanceScanner { "index_segments requires nearest() to be configured".into(), )); } @@ -2091,18 +2120,15 @@ index ef9d290..7110111 100644 // nearest() checks the current prefilter setting before accepting a // fragment-scoped search. Enable it before installing the query. if self.prefilter { -@@ -285,11 +288,141 @@ impl LanceScanner { +@@ -285,11 +318,139 @@ impl LanceScanner { if let Some(fts) = &self.fts_query { scanner.full_text_search(fts.clone())?; } + let distributed_fts = if let Some(context) = &self.fts_context { + context.validate_dataset_identity(&self.dataset)?; + let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; ++ self.apply_prepared_fts_fragment_filter(&mut scanner, context, &segments)?; + scanner.full_text_search(context.query.clone())?; -+ // Both STRICT and INDEX_ONLY context scans must use only the -+ // committed segments pinned in the context. In STRICT mode all -+ // current fragments were already proven covered during prepare. -+ scanner.fast_search(); + Some(PreparedFtsExecution { + context: Arc::clone(context), + segments, @@ -2112,6 +2138,7 @@ index ef9d290..7110111 100644 + } else { + None + }; ++ self.apply_filter(&mut scanner)?; if let Some(callback) = &self.scan_statistics_callback { scanner.scan_stats_callback(callback.clone()); } @@ -2234,7 +2261,7 @@ index ef9d290..7110111 100644 } /// Type of a dynamically named scan metric. -@@ -442,6 +575,26 @@ macro_rules! scanner_poison_check { +@@ -442,6 +603,26 @@ macro_rules! scanner_poison_check { }; } @@ -2261,7 +2288,7 @@ index ef9d290..7110111 100644 // --------------------------------------------------------------------------- // Scanner lifecycle + builder // --------------------------------------------------------------------------- -@@ -486,7 +639,7 @@ unsafe fn scanner_new_inner( +@@ -486,7 +667,7 @@ unsafe fn scanner_new_inner( #[unsafe(no_mangle)] pub unsafe extern "C" fn lance_scanner_set_limit(scanner: *mut LanceScanner, limit: i64) -> i32 { scanner_poison_check!(scanner, -1); @@ -2270,7 +2297,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Result { -@@ -504,7 +657,9 @@ unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Res +@@ -504,7 +685,9 @@ unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Res #[unsafe(no_mangle)] pub unsafe extern "C" fn lance_scanner_set_offset(scanner: *mut LanceScanner, offset: i64) -> i32 { scanner_poison_check!(scanner, -1); @@ -2281,7 +2308,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_offset_inner(scanner: *mut LanceScanner, offset: i64) -> Result { -@@ -525,10 +680,9 @@ pub unsafe extern "C" fn lance_scanner_set_batch_size( +@@ -525,10 +708,9 @@ pub unsafe extern "C" fn lance_scanner_set_batch_size( batch_size: i64, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2295,7 +2322,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_batch_size_inner(scanner: *mut LanceScanner, batch_size: i64) -> Result { -@@ -549,7 +703,9 @@ pub unsafe extern "C" fn lance_scanner_with_row_id( +@@ -549,7 +731,9 @@ pub unsafe extern "C" fn lance_scanner_with_row_id( enable: bool, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2306,7 +2333,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_with_row_id_inner(scanner: *mut LanceScanner, enable: bool) -> Result { -@@ -574,10 +730,9 @@ pub unsafe extern "C" fn lance_scanner_set_fragment_ids( +@@ -574,10 +758,9 @@ pub unsafe extern "C" fn lance_scanner_set_fragment_ids( len: usize, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2320,7 +2347,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_fragment_ids_inner( -@@ -631,10 +786,9 @@ pub unsafe extern "C" fn lance_scanner_set_substrait_filter( +@@ -631,10 +814,9 @@ pub unsafe extern "C" fn lance_scanner_set_substrait_filter( len: usize, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2334,7 +2361,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_substrait_filter_inner( -@@ -663,6 +817,50 @@ unsafe fn scanner_set_substrait_filter_inner( +@@ -663,6 +845,50 @@ unsafe fn scanner_set_substrait_filter_inner( Ok(0) } @@ -2385,7 +2412,7 @@ index ef9d290..7110111 100644 /// Register a callback that receives execution statistics after the scan stream /// is fully consumed to EOF. /// -@@ -693,10 +891,9 @@ pub unsafe extern "C" fn lance_scanner_set_statistics_callback( +@@ -693,10 +919,9 @@ pub unsafe extern "C" fn lance_scanner_set_statistics_callback( callback_ctx: *mut c_void, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2399,7 +2426,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_statistics_callback_inner( -@@ -734,6 +931,12 @@ unsafe fn scanner_set_statistics_callback_inner( +@@ -734,6 +959,12 @@ unsafe fn scanner_set_statistics_callback_inner( /// Close and free a scanner handle. /// @@ -2412,7 +2439,7 @@ index ef9d290..7110111 100644 /// Best-effort (issue #61): this drops a possibly-live /// `DatasetRecordBatchStream`, the highest-risk `Drop` in this crate. A /// panic raised while dropping the handle is caught and logged rather than -@@ -743,7 +946,9 @@ unsafe fn scanner_set_statistics_callback_inner( +@@ -743,7 +974,9 @@ unsafe fn scanner_set_statistics_callback_inner( pub unsafe extern "C" fn lance_scanner_close(scanner: *mut LanceScanner) { if !scanner.is_null() { swallow_unwind("lance_scanner_close", || unsafe { @@ -2423,7 +2450,7 @@ index ef9d290..7110111 100644 }); } } -@@ -770,27 +975,30 @@ pub unsafe extern "C" fn lance_scanner_to_arrow_stream( +@@ -770,27 +1003,30 @@ pub unsafe extern "C" fn lance_scanner_to_arrow_stream( scanner: *mut LanceScanner, out: *mut FFI_ArrowArrayStream, ) -> i32 { @@ -2469,7 +2496,7 @@ index ef9d290..7110111 100644 Err(payload) => { poisoned.store(true, Ordering::SeqCst); set_last_error( -@@ -848,14 +1056,18 @@ pub unsafe extern "C" fn lance_scanner_next( +@@ -848,14 +1084,18 @@ pub unsafe extern "C" fn lance_scanner_next( scanner: *mut LanceScanner, out: *mut *mut LanceBatch, ) -> i32 { @@ -2493,7 +2520,7 @@ index ef9d290..7110111 100644 let s = unsafe { &mut *scanner }; let poisoned = s.poison_flag(); match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { -@@ -919,16 +1131,22 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> +@@ -919,16 +1159,22 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> /// Start an async scan. The callback is invoked on a dedicated dispatcher thread /// when the ArrowArrayStream is ready. /// @@ -2521,7 +2548,7 @@ index ef9d290..7110111 100644 /// A panic anywhere in call-time setup (validation, scanner building, /// runtime access, task spawn) is caught by the entry guard below and still /// reported through the callback: `(ctx, -1, NULL)` with `LANCE_ERR_PANIC`, -@@ -939,9 +1157,13 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> +@@ -939,9 +1185,13 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> #[unsafe(no_mangle)] pub unsafe extern "C" fn lance_scanner_scan_async( scanner: *const LanceScanner, @@ -2536,7 +2563,7 @@ index ef9d290..7110111 100644 unsafe { scan_async_guarded(scanner, callback, callback_ctx, |s, cb, ctx| { scan_async_setup(s, cb, ctx) -@@ -1148,6 +1370,22 @@ unsafe fn scan_async_setup( +@@ -1148,6 +1398,22 @@ unsafe fn scan_async_setup( }); } @@ -2559,7 +2586,7 @@ index ef9d290..7110111 100644 // --------------------------------------------------------------------------- // Poll-based iteration (for cooperative async runtimes) // --------------------------------------------------------------------------- -@@ -1155,9 +1393,13 @@ unsafe fn scan_async_setup( +@@ -1155,9 +1421,13 @@ unsafe fn scan_async_setup( /// Poll for the next batch without blocking. /// /// - If data is already buffered, returns `LANCE_POLL_READY` immediately. @@ -2574,7 +2601,7 @@ index ef9d290..7110111 100644 /// /// The stream is lazily materialized on the first poll call (which will typically /// return PENDING while the stream opens). -@@ -1168,18 +1410,26 @@ unsafe fn scan_async_setup( +@@ -1168,18 +1438,26 @@ unsafe fn scan_async_setup( #[unsafe(no_mangle)] pub unsafe extern "C" fn lance_scanner_poll_next( scanner: *mut LanceScanner, @@ -2607,7 +2634,7 @@ index ef9d290..7110111 100644 let s = unsafe { &mut *scanner }; let poisoned = s.poison_flag(); match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { -@@ -1220,13 +1470,13 @@ unsafe fn scanner_poll_next_inner( +@@ -1220,13 +1498,13 @@ unsafe fn scanner_poll_next_inner( return LancePollStatus::Error; } @@ -2624,7 +2651,7 @@ index ef9d290..7110111 100644 // Enter the Tokio runtime context so internal I/O futures can access // the reactor. Without this, polling from a non-Tokio thread panics. let _guard = RT.enter(); -@@ -1264,39 +1514,66 @@ unsafe fn scanner_poll_next_inner( +@@ -1264,39 +1542,66 @@ unsafe fn scanner_poll_next_inner( struct CWakerContext { waker_fn: LanceWaker, ctx: *mut c_void, @@ -2703,7 +2730,7 @@ index ef9d290..7110111 100644 }; }, ); -@@ -1304,6 +1581,99 @@ fn make_raw_waker(waker_fn: LanceWaker, ctx: *mut c_void) -> RawWaker { +@@ -1304,6 +1609,99 @@ fn make_raw_waker(waker_fn: LanceWaker, ctx: *mut c_void) -> RawWaker { RawWaker::new(data, &VTABLE) } @@ -2803,7 +2830,7 @@ index ef9d290..7110111 100644 // --------------------------------------------------------------------------- // Vector search (Phase 2): setter knobs // --------------------------------------------------------------------------- -@@ -1313,7 +1683,8 @@ macro_rules! scanner_set_u32 { +@@ -1313,7 +1711,8 @@ macro_rules! scanner_set_u32 { #[unsafe(no_mangle)] pub unsafe extern "C" fn $name(scanner: *mut LanceScanner, value: u32) -> i32 { scanner_poison_check!(scanner, -1); @@ -2813,7 +2840,7 @@ index ef9d290..7110111 100644 (|| -> Result { if scanner.is_null() { return Err(lance_core::Error::invalid_input_source( -@@ -1324,8 +1695,7 @@ macro_rules! scanner_set_u32 { +@@ -1324,8 +1723,7 @@ macro_rules! scanner_set_u32 { (*scanner).$field = Some(value); } Ok(0) @@ -2823,7 +2850,7 @@ index ef9d290..7110111 100644 ) } }; -@@ -1338,7 +1708,9 @@ scanner_set_u32!(lance_scanner_set_ef, ef); +@@ -1338,7 +1736,9 @@ scanner_set_u32!(lance_scanner_set_ef, ef); #[unsafe(no_mangle)] pub unsafe extern "C" fn lance_scanner_set_metric(scanner: *mut LanceScanner, metric: i32) -> i32 { scanner_poison_check!(scanner, -1); @@ -2834,7 +2861,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_metric_inner(scanner: *mut LanceScanner, metric: i32) -> Result { -@@ -1370,7 +1742,9 @@ pub unsafe extern "C" fn lance_scanner_set_use_index( +@@ -1370,7 +1770,9 @@ pub unsafe extern "C" fn lance_scanner_set_use_index( enable: bool, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2845,7 +2872,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_use_index_inner(scanner: *mut LanceScanner, enable: bool) -> Result { -@@ -1391,7 +1765,9 @@ pub unsafe extern "C" fn lance_scanner_set_prefilter( +@@ -1391,7 +1793,9 @@ pub unsafe extern "C" fn lance_scanner_set_prefilter( enable: bool, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2856,7 +2883,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_prefilter_inner(scanner: *mut LanceScanner, enable: bool) -> Result { -@@ -1424,10 +1800,9 @@ pub unsafe extern "C" fn lance_scanner_set_index_segments( +@@ -1424,10 +1828,9 @@ pub unsafe extern "C" fn lance_scanner_set_index_segments( len: usize, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2870,7 +2897,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_set_index_segments_inner( -@@ -1485,10 +1860,9 @@ pub unsafe extern "C" fn lance_scanner_nearest( +@@ -1485,10 +1888,9 @@ pub unsafe extern "C" fn lance_scanner_nearest( k: u32, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2884,7 +2911,7 @@ index ef9d290..7110111 100644 } unsafe fn scanner_nearest_inner( -@@ -1510,9 +1884,9 @@ unsafe fn scanner_nearest_inner( +@@ -1510,9 +1912,9 @@ unsafe fn scanner_nearest_inner( )); } let s = unsafe { &mut *scanner }; @@ -2896,7 +2923,7 @@ index ef9d290..7110111 100644 )); } let column_str = unsafe { helpers::parse_c_string(column)? }.unwrap(); -@@ -1586,10 +1960,9 @@ pub unsafe extern "C" fn lance_scanner_full_text_search( +@@ -1586,10 +1988,9 @@ pub unsafe extern "C" fn lance_scanner_full_text_search( max_fuzzy_distance: u32, ) -> i32 { scanner_poison_check!(scanner, -1); @@ -2910,7 +2937,7 @@ index ef9d290..7110111 100644 } unsafe fn fts_inner( -@@ -1611,6 +1984,11 @@ unsafe fn fts_inner( +@@ -1611,6 +2012,11 @@ unsafe fn fts_inner( "cannot call full_text_search after nearest; they are mutually exclusive".into(), )); } @@ -2922,7 +2949,7 @@ index ef9d290..7110111 100644 let query_str = unsafe { helpers::parse_c_string(query)? } .unwrap() -@@ -1633,13 +2011,89 @@ unsafe fn fts_inner( +@@ -1633,13 +2039,89 @@ unsafe fn fts_inner( Ok(0) } @@ -3013,7 +3040,7 @@ index ef9d290..7110111 100644 use arrow_array::{Int32Array, RecordBatch, StringArray}; use arrow_schema::{DataType, Field, Schema}; -@@ -1706,6 +2160,198 @@ mod tests { +@@ -1706,6 +2188,198 @@ mod tests { unsafe extern "C" fn noop_waker(_ctx: *mut c_void) {} @@ -3212,7 +3239,7 @@ index ef9d290..7110111 100644 #[test] fn poisoned_scanner_rejects_setters_with_panic_code() { let (_tmp, uri) = create_test_dataset(); -@@ -1745,12 +2391,17 @@ mod tests { +@@ -1745,12 +2419,17 @@ mod tests { let (dataset, scanner) = open_dataset_and_scanner(&uri); poison(scanner); @@ -3231,7 +3258,7 @@ index ef9d290..7110111 100644 unsafe { lance_scanner_close(scanner); lance_dataset_close(dataset); -@@ -1763,13 +2414,20 @@ mod tests { +@@ -1763,13 +2442,20 @@ mod tests { let (dataset, scanner) = open_dataset_and_scanner(&uri); poison(scanner); @@ -3255,7 +3282,7 @@ index ef9d290..7110111 100644 unsafe { lance_scanner_close(scanner); lance_dataset_close(dataset); -@@ -1809,7 +2467,7 @@ mod tests { +@@ -1809,7 +2495,7 @@ mod tests { let (dataset, scanner) = open_dataset_and_scanner(&uri); poison(scanner); @@ -3945,7 +3972,7 @@ index f4f418e..d785082 100644 + } } diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index daa7425..17dc6c6 100644 +index daa7425..f83b004 100644 --- a/tests/c_api_test.rs +++ b/tests/c_api_test.rs @@ -993,7 +993,7 @@ fn test_scanner_scan_async() { @@ -4057,7 +4084,7 @@ index daa7425..17dc6c6 100644 #[test] fn test_index_segment_metadata_parse_rejects_malformed_and_dangerous_input() { use prost::Message; -@@ -5543,6 +5600,431 @@ fn test_fts_fuzzy() { +@@ -5543,6 +5600,509 @@ fn test_fts_fuzzy() { unsafe { lance_dataset_close(ds) }; } @@ -4144,6 +4171,84 @@ index daa7425..17dc6c6 100644 +} + +#[test] ++fn test_prepared_fts_row_id_output_is_explicit() { ++ let (_tmp, uri) = create_test_dataset(); ++ let uri_c = c_str(&uri); ++ let column = c_str("name"); ++ let query = c_str("alice"); ++ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); ++ ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ dataset, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ inverted_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ let context = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!context.is_null(), "{}", unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() ++ }); ++ ++ let id = c_str("id"); ++ let columns = [id.as_ptr(), ptr::null()]; ++ let scan_schema = |with_row_id: bool| { ++ let scanner = unsafe { lance_scanner_new(dataset, columns.as_ptr(), ptr::null()) }; ++ assert!(!scanner.is_null()); ++ if with_row_id { ++ assert_eq!(unsafe { lance_scanner_with_row_id(scanner, true) }, 0); ++ } ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, ++ 0 ++ ); ++ let mut stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, ++ 0, ++ "{}", ++ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; ++ let schema = reader.schema(); ++ let rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert!(rows > 0); ++ unsafe { lance_scanner_close(scanner) }; ++ schema ++ }; ++ ++ let without_row_id = scan_schema(false); ++ assert_eq!(without_row_id.fields().len(), 2); ++ assert!(without_row_id.field_with_name("id").is_ok()); ++ assert!(without_row_id.field_with_name("_score").is_ok()); ++ assert!(without_row_id.field_with_name("_rowid").is_err()); ++ ++ let with_row_id = scan_schema(true); ++ assert_eq!(with_row_id.fields().len(), 3); ++ assert!(with_row_id.field_with_name("id").is_ok()); ++ assert!(with_row_id.field_with_name("_score").is_ok()); ++ assert!(with_row_id.field_with_name("_rowid").is_ok()); ++ ++ unsafe { lance_fts_query_context_close(context) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ ++#[test] +fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { + let (_tmp, uri) = create_test_dataset(); + let uri_c = c_str(&uri); @@ -4489,7 +4594,7 @@ index daa7425..17dc6c6 100644 #[test] fn test_nearest_after_fts_is_rejected() { let (_tmp, uri) = create_vector_dataset(64, 8); -@@ -6418,18 +6900,17 @@ fn test_scanner_with_substrait_filter() { +@@ -6418,18 +6978,17 @@ fn test_scanner_with_substrait_filter() { #[test] fn test_scanner_substrait_filter_overrides_sql_filter() { @@ -4510,7 +4615,7 @@ index daa7425..17dc6c6 100644 let bytes = substrait_id_gt_3(); let rc = unsafe { lance_scanner_set_substrait_filter(scanner, bytes.as_ptr(), bytes.len()) }; assert_eq!(rc, 0); -@@ -6446,6 +6927,81 @@ fn test_scanner_substrait_filter_overrides_sql_filter() { +@@ -6446,6 +7005,210 @@ fn test_scanner_substrait_filter_overrides_sql_filter() { unsafe { lance_dataset_close(ds) }; } @@ -4551,6 +4656,135 @@ index daa7425..17dc6c6 100644 +} + +#[test] ++fn test_scanner_additional_sql_filter_preserves_metadata_primary_filter() { ++ let (_tmp, uri) = create_test_dataset(); ++ let c_uri = c_str(&uri); ++ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; ++ assert!(!ds.is_null()); ++ ++ let primary = c_str( ++ "_rowid IS NOT NULL AND _rowaddr IS NOT NULL \ ++ AND _row_created_at_version IS NOT NULL \ ++ AND _row_last_updated_at_version IS NOT NULL", ++ ); ++ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), primary.as_ptr()) }; ++ assert!(!scanner.is_null()); ++ ++ let additional = c_str("id > 3"); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, additional.as_ptr()) }, ++ 0 ++ ); ++ ++ let mut ffi_stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, ++ 0 ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); ++ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert_eq!(total_rows, 2, "metadata predicate AND id > 3"); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_dataset_close(ds) }; ++} ++ ++#[test] ++fn test_scanner_additional_sql_filter_preserves_distance_primary_filter() { ++ let (_tmp, uri) = create_vector_dataset(16, 8); ++ let c_uri = c_str(&uri); ++ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; ++ assert!(!ds.is_null()); ++ ++ let primary = c_str("_distance IS NOT NULL"); ++ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), primary.as_ptr()) }; ++ assert!(!scanner.is_null()); ++ let query = [0.0_f32; 8]; ++ let column = c_str("embedding"); ++ assert_eq!( ++ unsafe { ++ lance_scanner_nearest( ++ scanner, ++ column.as_ptr(), ++ query.as_ptr().cast(), ++ query.len(), ++ LanceDataType::Float32 as i32, ++ 16, ++ ) ++ }, ++ 0 ++ ); ++ let additional = c_str("id < 3"); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, additional.as_ptr()) }, ++ 0 ++ ); ++ ++ let mut ffi_stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, ++ 0 ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); ++ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert_eq!(total_rows, 3, "_distance predicate AND id < 3"); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_dataset_close(ds) }; ++} ++ ++#[test] ++fn test_scanner_additional_sql_filter_preserves_score_primary_filter() { ++ let (_tmp, uri) = create_test_dataset(); ++ let c_uri = c_str(&uri); ++ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; ++ assert!(!ds.is_null()); ++ ++ let column = c_str("name"); ++ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ ds, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ inverted_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ ++ let primary = c_str("_score IS NOT NULL"); ++ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), primary.as_ptr()) }; ++ assert!(!scanner.is_null()); ++ let query = c_str("alice"); ++ let columns = [column.as_ptr(), ptr::null()]; ++ assert_eq!( ++ unsafe { lance_scanner_full_text_search(scanner, query.as_ptr(), columns.as_ptr(), 0) }, ++ 0 ++ ); ++ let additional = c_str("id >= 1"); ++ assert_eq!( ++ unsafe { lance_scanner_additional_sql_filter(scanner, additional.as_ptr()) }, ++ 0 ++ ); ++ ++ let mut ffi_stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, ++ 0 ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); ++ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert_eq!(total_rows, 1, "_score predicate AND id >= 1"); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_dataset_close(ds) }; ++} ++ ++#[test] +fn test_scanner_additional_sql_filter_rejects_invalid_inputs() { + let (_tmp, uri) = create_test_dataset(); + let c_uri = c_str(&uri); @@ -4592,7 +4826,7 @@ index daa7425..17dc6c6 100644 #[test] fn test_scanner_set_substrait_filter_invalid_inputs() { let (_tmp, uri) = create_test_dataset(); -@@ -10388,8 +10944,8 @@ fn test_add_columns_nulls_released_schema_rejected() { +@@ -10388,8 +11151,8 @@ fn test_add_columns_nulls_released_schema_rejected() { #[test] fn test_add_columns_nulls_non_utf8_format_rejected() { // A non-NULL but non-UTF-8 top-level `format` must be rejected at the FFI From 026c9ff3aad8cf7133f77e60f4e06d87f03cee08 Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Tue, 1 Sep 2026 13:07:47 +0800 Subject: [PATCH 4/7] [chore](lance) Upgrade Lance-C to v0.1.8 ### What problem does this PR solve? Issue Number: None Related PR: lance-format/lance-c#69 Problem Summary: Lance-C v0.1.8 now contains the upstream changes previously carried by the v0.1.7 PR #64 and runtime-filter patches. Upgrade the bundled source archive and checksum, remove both obsolete v0.1.7 patches, and retain only the current two-commit snapshot of upstream PR #69 for prepared FTS INDEX_ONLY plan scoping and explicit _rowid behavior. ### Release note Upgrade the bundled Lance-C dependency to v0.1.8 and apply upstream PR #69. ### Check List (For Author) - Test: Manual test - Verified the official archive MD5, applied PR #69 to a clean v0.1.8 archive with zero fuzz, compared every tracked file byte-for-byte with PR head 6f0fae4, checked shell syntax, Rustfmt, stale references, and upstream CI status - Behavior changed: Yes. Prepared FTS INDEX_ONLY scans use the PR #69 plan rewrite without scanner-wide fast_search - Does this need documentation: No --- thirdparty/download-thirdparty.sh | 11 +- thirdparty/patches/lance-c-0.1.7-pr-64.patch | 1522 ----- .../lance-c-0.1.7-runtime-filter.patch | 5138 ----------------- thirdparty/patches/lance-c-0.1.8-pr-69.patch | 653 +++ thirdparty/vars.sh | 8 +- 5 files changed, 660 insertions(+), 6672 deletions(-) delete mode 100644 thirdparty/patches/lance-c-0.1.7-pr-64.patch delete mode 100644 thirdparty/patches/lance-c-0.1.7-runtime-filter.patch create mode 100644 thirdparty/patches/lance-c-0.1.8-pr-69.patch diff --git a/thirdparty/download-thirdparty.sh b/thirdparty/download-thirdparty.sh index 85631eaadda648..3d05caa056bc7d 100755 --- a/thirdparty/download-thirdparty.sh +++ b/thirdparty/download-thirdparty.sh @@ -774,19 +774,14 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " PAIMON_CPP " ]]; then echo "Finished patching ${PAIMON_CPP_SOURCE}" fi -# Apply Doris lance-c patches in dependency order. +# Apply Doris lance-c patches. if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then - if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.7" ]]; then + if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.8" ]]; then cd "${TP_SOURCE_DIR}/${LANCE_C_SOURCE}" if [[ ! -f "${PATCHED_MARK}" ]]; then - patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.7-pr-64.patch" + patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.8-pr-69.patch" touch "${PATCHED_MARK}" fi - lance_runtime_filter_mark="patched_mark_runtime_filter" - if [[ ! -f "${lance_runtime_filter_mark}" ]]; then - patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.7-runtime-filter.patch" - touch "${lance_runtime_filter_mark}" - fi cd - fi echo "Finished patching ${LANCE_C_SOURCE}" diff --git a/thirdparty/patches/lance-c-0.1.7-pr-64.patch b/thirdparty/patches/lance-c-0.1.7-pr-64.patch deleted file mode 100644 index 17f91bc7c318ad..00000000000000 --- a/thirdparty/patches/lance-c-0.1.7-pr-64.patch +++ /dev/null @@ -1,1522 +0,0 @@ -From e3320c1e7d5c72234b3b44e9e7e9a93a72fe488c Mon Sep 17 00:00:00 2001 -From: zhangstar333 -Date: Mon, 24 Aug 2026 16:37:33 +0800 -Subject: [PATCH 1/3] update - ---- - include/lance/lance.h | 73 ++++++++++++++++ - include/lance/lance.hpp | 10 +++ - src/scanner.rs | 179 +++++++++++++++++++++++++++++++++++++- - tests/c_api_test.rs | 188 +++++++++++++++++++++++++++++++++++++++- - 4 files changed, 448 insertions(+), 2 deletions(-) - -diff --git a/include/lance/lance.h b/include/lance/lance.h -index 986905b..c6c3985 100644 ---- a/include/lance/lance.h -+++ b/include/lance/lance.h -@@ -863,6 +863,79 @@ int32_t lance_scanner_set_substrait_filter( - size_t len - ); - -+/** Type of a dynamically named scan metric. */ -+typedef enum { -+ LANCE_SCAN_METRIC_COUNT = 0, -+ LANCE_SCAN_METRIC_TIME_NANOSECONDS = 1, -+} LanceScanMetricKind; -+ -+/** -+ * Borrowed view of one dynamically named scan metric. -+ * -+ * `name` is not NUL-terminated. `name` and this structure are valid only for -+ * the duration of the LanceScanStatisticsCallback invocation. -+ */ -+typedef struct { -+ const char* name; -+ size_t name_len; -+ LanceScanMetricKind kind; -+ uint64_t value; -+} LanceScanMetric; -+ -+/** -+ * Borrowed view of the execution statistics for one finalized scan. -+ * -+ * The fixed fields are stable summary metrics. `metrics` contains additional -+ * implementation-specific counters and timings. Those names are not a stable -+ * API and are intended for diagnostics and profiles. Dynamic metrics are -+ * best-effort and may be omitted if they cannot be materialized. `metrics` is -+ * NULL when `metrics_len` is zero. -+ */ -+typedef struct { -+ uint64_t iops; -+ uint64_t requests; -+ uint64_t bytes_read; -+ uint64_t indices_loaded; -+ uint64_t index_partitions_loaded; -+ uint64_t index_comparisons; -+ const LanceScanMetric* metrics; -+ size_t metrics_len; -+} LanceScanStatistics; -+ -+/** -+ * Receives scan statistics when a stream reaches EOF, fails, or is released. -+ * -+ * The statistics and all nested pointers are borrowed and valid only for the -+ * duration of this call. The callback may run on the thread that consumes or -+ * releases the scan stream and must therefore be thread-safe. It must return -+ * normally without throwing an exception or unwinding, and must not call any -+ * `lance_scanner_*` function with the originating scanner. -+ * -+ * Scan statistics are diagnostic and best-effort. The callback must handle its -+ * own errors and must not use them to abort or throw across this FFI boundary. -+ */ -+typedef void (*LanceScanStatisticsCallback)( -+ void* callback_ctx, -+ const LanceScanStatistics* statistics -+); -+ -+/** -+ * Register the execution-statistics callback for this scanner. -+ * -+ * Must be called before starting the scan; registering after the scan starts -+ * returns an error. `callback` must not be NULL. `callback_ctx` may be NULL. A -+ * non-NULL `callback_ctx` must remain valid, and `callback` must remain valid, -+ * until the stream reaches EOF, fails, or is released. Replaces a previously -+ * registered callback. -+ * -+ * @return 0 on success, -1 on error -+ */ -+int32_t lance_scanner_set_statistics_callback( -+ LanceScanner* scanner, -+ LanceScanStatisticsCallback callback, -+ void* callback_ctx -+); -+ - /** Close and free a scanner handle. */ - void lance_scanner_close(LanceScanner* scanner); - -diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp -index afce358..9440a23 100644 ---- a/include/lance/lance.hpp -+++ b/include/lance/lance.hpp -@@ -1127,6 +1127,16 @@ class Scanner { - return substrait_filter(bytes.data(), bytes.size()); - } - -+ /// Register a callback for scan execution statistics before starting the scan. -+ /// The callback may run on the thread that consumes or releases the stream. It -+ /// must be thread-safe, must not throw, and must not re-enter the originating -+ /// scanner. A non-null callback context must outlive the exported stream. -+ Scanner& statistics_callback(LanceScanStatisticsCallback callback, void* callback_ctx) { -+ if (lance_scanner_set_statistics_callback(handle_.get(), callback, callback_ctx) != 0) -+ check_error(); -+ return *this; -+ } -+ - /// Restrict the next k-NN query to a subset of vector index segments. - /// Pass `len` 16-byte UUIDs concatenated as a single byte buffer - /// (total bytes = `len * 16`). Pass len=0 (and any pointer) to clear. -diff --git a/src/scanner.rs b/src/scanner.rs -index f44b82f..d95089e 100644 ---- a/src/scanner.rs -+++ b/src/scanner.rs -@@ -14,7 +14,9 @@ use arrow::ffi_stream::FFI_ArrowArrayStream; - use arrow_schema::SchemaRef; - use futures::{FutureExt, Stream, StreamExt}; - use lance::Dataset; --use lance::dataset::scanner::DatasetRecordBatchStream; -+use lance::dataset::scanner::{ -+ DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts, -+}; - use lance_core::Result; - use lance_index::scalar::FullTextSearchQuery; - use lance_io::stream::RecordBatchStream; -@@ -69,6 +71,8 @@ pub struct LanceScanner { - // the spawned async task can poison the handle from outside this call - // frame via `poison_flag()`. - poisoned: Arc, -+ scan_statistics_callback: Option, -+ scan_started: AtomicBool, - // Materialized on first iteration call - stream: Option>>, - #[allow(dead_code)] -@@ -122,6 +126,8 @@ impl LanceScanner { - prefilter: false, - fts_query: None, - poisoned: Arc::new(AtomicBool::new(false)), -+ scan_statistics_callback: None, -+ scan_started: AtomicBool::new(false), - stream: None, - schema: None, - } -@@ -157,6 +163,7 @@ impl LanceScanner { - - /// Build the underlying Scanner and open a stream. - fn materialize_stream(&mut self) -> Result<()> { -+ self.scan_started.store(true, Ordering::Release); - let mut scanner = self.dataset.scan(); - if let Some(cols) = &self.columns { - scanner.project(cols)?; -@@ -212,6 +219,9 @@ impl LanceScanner { - if let Some(fts) = &self.fts_query { - scanner.full_text_search(fts.clone())?; - } -+ if let Some(callback) = &self.scan_statistics_callback { -+ scanner.scan_stats_callback(callback.clone()); -+ } - let stream = block_on(scanner.try_into_stream())?; - self.schema = Some(stream.schema()); - self.stream = Some(Box::pin(stream)); -@@ -220,6 +230,7 @@ impl LanceScanner { - - /// Build a Scanner (without materializing) and return it. - fn build_scanner(&self) -> Result { -+ self.scan_started.store(true, Ordering::Release); - let mut scanner = self.dataset.scan(); - if let Some(cols) = &self.columns { - scanner.project(cols)?; -@@ -274,10 +285,122 @@ impl LanceScanner { - if let Some(fts) = &self.fts_query { - scanner.full_text_search(fts.clone())?; - } -+ if let Some(callback) = &self.scan_statistics_callback { -+ scanner.scan_stats_callback(callback.clone()); -+ } - Ok(scanner) - } - } - -+/// Type of a dynamically named scan metric. -+#[repr(i32)] -+#[derive(Clone, Copy, Debug, PartialEq, Eq)] -+pub enum LanceScanMetricKind { -+ /// Monotonically accumulated counter. -+ Count = 0, -+ /// Accumulated duration in nanoseconds. -+ TimeNanoseconds = 1, -+} -+ -+/// Borrowed view of one dynamically named scan metric. -+/// -+/// `name` is not NUL-terminated. Both `name` and this structure are valid only -+/// for the duration of the scan statistics callback. -+#[repr(C)] -+#[derive(Clone, Copy, Debug)] -+pub struct LanceScanMetric { -+ pub name: *const c_char, -+ pub name_len: usize, -+ pub kind: LanceScanMetricKind, -+ pub value: u64, -+} -+ -+/// Borrowed view of the execution statistics for one completed scan. -+/// -+/// The fixed fields are stable summary metrics. `metrics` contains additional -+/// implementation-specific counters and timings and is valid only for the -+/// duration of the callback. -+#[repr(C)] -+#[derive(Clone, Copy, Debug)] -+pub struct LanceScanStatistics { -+ pub iops: u64, -+ pub requests: u64, -+ pub bytes_read: u64, -+ pub indices_loaded: u64, -+ pub index_partitions_loaded: u64, -+ pub index_comparisons: u64, -+ pub metrics: *const LanceScanMetric, -+ pub metrics_len: usize, -+} -+ -+/// Callback invoked when a scan stream reaches EOF, fails, or is released. -+/// -+/// The callback is an FFI boundary and must return normally without unwinding -+/// or throwing an exception. It must not call back into `lance_scanner_*` with -+/// the originating scanner. -+pub type LanceScanStatisticsCallback = -+ Option; -+ -+struct SendScanStatisticsCallback { -+ callback: unsafe extern "C" fn(*mut c_void, *const LanceScanStatistics), -+ ctx: *mut c_void, -+} -+ -+// SAFETY: The C API requires the callback and its context to remain valid and -+// safe to invoke from the thread that consumes or releases the scan stream. -+unsafe impl Send for SendScanStatisticsCallback {} -+unsafe impl Sync for SendScanStatisticsCallback {} -+ -+impl SendScanStatisticsCallback { -+ fn invoke(&self, counts: &ExecutionSummaryCounts) { -+ // Dynamic profile metrics are best-effort. Use fallible reservation so -+ // allocation failure omits them instead of aborting the embedding process. -+ let mut metrics = Vec::new(); -+ if let Some(metrics_len) = counts.all_counts.len().checked_add(counts.all_times.len()) -+ && metrics.try_reserve_exact(metrics_len).is_ok() -+ { -+ metrics.extend( -+ counts -+ .all_counts -+ .iter() -+ .map(|(name, value)| LanceScanMetric { -+ name: name.as_ptr().cast(), -+ name_len: name.len(), -+ kind: LanceScanMetricKind::Count, -+ value: *value as u64, -+ }), -+ ); -+ metrics.extend( -+ counts -+ .all_times -+ .iter() -+ .map(|(name, value)| LanceScanMetric { -+ name: name.as_ptr().cast(), -+ name_len: name.len(), -+ kind: LanceScanMetricKind::TimeNanoseconds, -+ value: *value as u64, -+ }), -+ ); -+ } -+ -+ let statistics = LanceScanStatistics { -+ iops: counts.iops as u64, -+ requests: counts.requests as u64, -+ bytes_read: counts.bytes_read as u64, -+ indices_loaded: counts.indices_loaded as u64, -+ index_partitions_loaded: counts.parts_loaded as u64, -+ index_comparisons: counts.index_comparisons as u64, -+ metrics: if metrics.is_empty() { -+ ptr::null() -+ } else { -+ metrics.as_ptr() -+ }, -+ metrics_len: metrics.len(), -+ }; -+ unsafe { (self.callback)(self.ctx, &statistics) }; -+ } -+} -+ - // --------------------------------------------------------------------------- - // Poison check shared by all `lance_scanner_*` entry points - // --------------------------------------------------------------------------- -@@ -529,6 +652,60 @@ unsafe fn scanner_set_substrait_filter_inner( - Ok(0) - } - -+/// Register a callback that receives execution statistics when the scan stream -+/// reaches EOF, fails, or is released. -+/// -+/// The callback and `callback_ctx` must remain valid until the scan stream is -+/// finalized. Metric names and arrays passed to the callback are borrowed and -+/// must be copied if the caller needs to retain them. The callback must be -+/// thread-safe, must return normally without unwinding or throwing an -+/// exception, and must not call `lance_scanner_*` with the originating scanner. -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_scanner_set_statistics_callback( -+ scanner: *mut LanceScanner, -+ callback: LanceScanStatisticsCallback, -+ callback_ctx: *mut c_void, -+) -> i32 { -+ scanner_poison_check!(scanner, -1); -+ ffi_try!( -+ unsafe { scanner_set_statistics_callback_inner(scanner, callback, callback_ctx) }, -+ neg -+ ) -+} -+ -+unsafe fn scanner_set_statistics_callback_inner( -+ scanner: *mut LanceScanner, -+ callback: LanceScanStatisticsCallback, -+ callback_ctx: *mut c_void, -+) -> Result { -+ if scanner.is_null() { -+ return Err(lance_core::Error::invalid_input_source( -+ "scanner is NULL".into(), -+ )); -+ } -+ let Some(callback) = callback else { -+ return Err(lance_core::Error::invalid_input_source( -+ "statistics callback is NULL".into(), -+ )); -+ }; -+ -+ let s = unsafe { &mut *scanner }; -+ if s.scan_started.load(Ordering::Acquire) { -+ return Err(lance_core::Error::invalid_input_source( -+ "statistics callback must be registered before the scan starts".into(), -+ )); -+ } -+ -+ let callback = SendScanStatisticsCallback { -+ callback, -+ ctx: callback_ctx, -+ }; -+ s.scan_statistics_callback = Some(Arc::new(move |counts: &ExecutionSummaryCounts| { -+ callback.invoke(counts); -+ })); -+ Ok(0) -+} -+ - /// Close and free a scanner handle. - /// - /// Best-effort (issue #61): this drops a possibly-live -diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index d6cd928..db35fea 100644 ---- a/tests/c_api_test.rs -+++ b/tests/c_api_test.rs -@@ -6,7 +6,7 @@ - //! These tests call the `extern "C"` functions directly from Rust, - //! validating the C API contract without needing a C compiler. - --use std::ffi::{CString, c_char}; -+use std::ffi::{CString, c_char, c_void}; - use std::process::Command; - use std::ptr; - use std::sync::Arc; -@@ -99,6 +99,58 @@ fn c_str(s: &str) -> CString { - CString::new(s).unwrap() - } - -+#[derive(Default)] -+struct CapturedScanStatistics { -+ calls: usize, -+ iops: u64, -+ requests: u64, -+ bytes_read: u64, -+ indices_loaded: u64, -+ index_partitions_loaded: u64, -+ index_comparisons: u64, -+ metrics: Vec<(String, LanceScanMetricKind, u64)>, -+} -+ -+unsafe extern "C" fn capture_scan_statistics( -+ callback_ctx: *mut c_void, -+ statistics: *const LanceScanStatistics, -+) { -+ assert!(!callback_ctx.is_null()); -+ assert!(!statistics.is_null()); -+ let captured = unsafe { &mut *callback_ctx.cast::() }; -+ let statistics = unsafe { &*statistics }; -+ let metrics = if statistics.metrics_len == 0 { -+ &[] -+ } else { -+ assert!(!statistics.metrics.is_null()); -+ unsafe { std::slice::from_raw_parts(statistics.metrics, statistics.metrics_len) } -+ }; -+ -+ captured.calls += 1; -+ captured.iops = statistics.iops; -+ captured.requests = statistics.requests; -+ captured.bytes_read = statistics.bytes_read; -+ captured.indices_loaded = statistics.indices_loaded; -+ captured.index_partitions_loaded = statistics.index_partitions_loaded; -+ captured.index_comparisons = statistics.index_comparisons; -+ captured.metrics = metrics -+ .iter() -+ .map(|metric| { -+ let name = if metric.name_len == 0 { -+ &[] -+ } else { -+ assert!(!metric.name.is_null()); -+ unsafe { std::slice::from_raw_parts(metric.name.cast::(), metric.name_len) } -+ }; -+ ( -+ std::str::from_utf8(name).unwrap().to_owned(), -+ metric.kind, -+ metric.value, -+ ) -+ }) -+ .collect(); -+} -+ - /// Helper: build a tiny dataset whose `value` column is nullable AND contains - /// at least one NULL. Used by tests that need to exercise upstream's - /// nullability-tightening pre-scan failure path. -@@ -284,6 +336,140 @@ fn test_scanner_to_arrow_stream() { - unsafe { lance_dataset_close(ds) }; - } - -+#[test] -+fn test_scanner_statistics_callback_with_next_multi_fragment() { -+ let (_tmp, uri) = create_multi_fragment_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ assert_eq!(unsafe { lance_dataset_fragment_count(ds) }, 2); -+ -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ loop { -+ let mut batch = ptr::null_mut(); -+ match unsafe { lance_scanner_next(scanner, &mut batch) } { -+ 0 => unsafe { lance_batch_free(batch) }, -+ 1 => break, -+ status => panic!("scanner_next returned error: {status}"), -+ } -+ } -+ -+ assert_eq!(captured.calls, 1); -+ assert!(captured.bytes_read > 0); -+ assert!(captured.requests > 0); -+ assert!(captured.metrics.iter().all(|(name, _, _)| !name.is_empty())); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_statistics_callback_with_arrow_stream() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!(unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, 0); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream) }.unwrap(); -+ assert_eq!(reader.map(|batch| batch.unwrap().num_rows()).sum::(), 5); -+ assert_eq!(captured.calls, 1); -+ assert!(captured.bytes_read > 0); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_statistics_callback_rejects_null_inputs() { -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ ptr::null_mut(), -+ Some(capture_scan_statistics), -+ ptr::null_mut(), -+ ) -+ }, -+ -1 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_statistics_callback(scanner, None, ptr::null_mut()) }, -+ -1 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_statistics_callback_rejects_registration_after_scan_started() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ -+ let mut batch = ptr::null_mut(); -+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, 0); -+ assert!(!batch.is_null()); -+ unsafe { lance_batch_free(batch) }; -+ -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ -1 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ let error = take_last_error_message(); -+ assert!(error.contains("before the scan starts"), "{error}"); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ assert_eq!(captured.calls, 0); -+ unsafe { lance_dataset_close(ds) }; -+} -+ - #[test] - fn test_scanner_with_filter() { - let (_tmp, uri) = create_test_dataset(); - -From 8e6e92140031c06184da079b8f6c194799c0de88 Mon Sep 17 00:00:00 2001 -From: zhangstar333 -Date: Mon, 24 Aug 2026 17:04:51 +0800 -Subject: [PATCH 2/3] formatter - ---- - include/lance/lance.h | 23 ++++--- - include/lance/lance.hpp | 9 +-- - src/scanner.rs | 23 ++++--- - tests/c_api_test.rs | 134 +++++++++++++++++++++++++++++++++++++++- - 4 files changed, 165 insertions(+), 24 deletions(-) - -diff --git a/include/lance/lance.h b/include/lance/lance.h -index c6c3985..1a6822b 100644 ---- a/include/lance/lance.h -+++ b/include/lance/lance.h -@@ -883,7 +883,7 @@ typedef struct { - } LanceScanMetric; - - /** -- * Borrowed view of the execution statistics for one finalized scan. -+ * Borrowed view of the execution statistics for one fully consumed scan. - * - * The fixed fields are stable summary metrics. `metrics` contains additional - * implementation-specific counters and timings. Those names are not a stable -@@ -903,13 +903,13 @@ typedef struct { - } LanceScanStatistics; - - /** -- * Receives scan statistics when a stream reaches EOF, fails, or is released. -+ * Receives scan statistics after a stream is fully consumed to EOF. - * - * The statistics and all nested pointers are borrowed and valid only for the -- * duration of this call. The callback may run on the thread that consumes or -- * releases the scan stream and must therefore be thread-safe. It must return -- * normally without throwing an exception or unwinding, and must not call any -- * `lance_scanner_*` function with the originating scanner. -+ * duration of this call. The callback may run on the thread that observes EOF -+ * and must therefore be thread-safe. It must return normally without throwing -+ * an exception or unwinding, and must not call any `lance_scanner_*` function -+ * with the originating scanner. - * - * Scan statistics are diagnostic and best-effort. The callback must handle its - * own errors and must not use them to abort or throw across this FFI boundary. -@@ -925,8 +925,15 @@ typedef void (*LanceScanStatisticsCallback)( - * Must be called before starting the scan; registering after the scan starts - * returns an error. `callback` must not be NULL. `callback_ctx` may be NULL. A - * non-NULL `callback_ctx` must remain valid, and `callback` must remain valid, -- * until the stream reaches EOF, fails, or is released. Replaces a previously -- * registered callback. -+ * until the callback returns or, if the callback has not run, until the owning -+ * scan stream is released. For `lance_scanner_next` and -+ * `lance_scanner_poll_next`, the scanner owns the stream. For an exported -+ * ArrowArrayStream, the Arrow stream owns it independently of the scanner. -+ * -+ * The callback is invoked exactly once when the stream is fully consumed to -+ * EOF. It is not guaranteed to run if execution fails, the scan is cancelled, -+ * or the scanner / ArrowArrayStream is released before EOF. Replaces a -+ * previously registered callback. - * - * @return 0 on success, -1 on error - */ -diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp -index 9440a23..4268801 100644 ---- a/include/lance/lance.hpp -+++ b/include/lance/lance.hpp -@@ -1127,10 +1127,11 @@ class Scanner { - return substrait_filter(bytes.data(), bytes.size()); - } - -- /// Register a callback for scan execution statistics before starting the scan. -- /// The callback may run on the thread that consumes or releases the stream. It -- /// must be thread-safe, must not throw, and must not re-enter the originating -- /// scanner. A non-null callback context must outlive the exported stream. -+ /// Register a callback for scan statistics after successful full exhaustion. -+ /// The callback is not guaranteed on error, cancellation, or early release. It -+ /// may run on the thread that observes EOF, must be thread-safe, must not throw, -+ /// and must not re-enter the originating scanner. The callback and a non-null -+ /// context must remain valid until the callback returns or the stream is released. - Scanner& statistics_callback(LanceScanStatisticsCallback callback, void* callback_ctx) { - if (lance_scanner_set_statistics_callback(handle_.get(), callback, callback_ctx) != 0) - check_error(); -diff --git a/src/scanner.rs b/src/scanner.rs -index d95089e..414c269 100644 ---- a/src/scanner.rs -+++ b/src/scanner.rs -@@ -315,7 +315,7 @@ pub struct LanceScanMetric { - pub value: u64, - } - --/// Borrowed view of the execution statistics for one completed scan. -+/// Borrowed view of the execution statistics for one fully consumed scan. - /// - /// The fixed fields are stable summary metrics. `metrics` contains additional - /// implementation-specific counters and timings and is valid only for the -@@ -333,7 +333,7 @@ pub struct LanceScanStatistics { - pub metrics_len: usize, - } - --/// Callback invoked when a scan stream reaches EOF, fails, or is released. -+/// Callback invoked after a scan stream is fully consumed to EOF. - /// - /// The callback is an FFI boundary and must return normally without unwinding - /// or throwing an exception. It must not call back into `lance_scanner_*` with -@@ -347,7 +347,7 @@ struct SendScanStatisticsCallback { - } - - // SAFETY: The C API requires the callback and its context to remain valid and --// safe to invoke from the thread that consumes or releases the scan stream. -+// safe to invoke from the thread that observes the scan stream's EOF. - unsafe impl Send for SendScanStatisticsCallback {} - unsafe impl Sync for SendScanStatisticsCallback {} - -@@ -652,14 +652,17 @@ unsafe fn scanner_set_substrait_filter_inner( - Ok(0) - } - --/// Register a callback that receives execution statistics when the scan stream --/// reaches EOF, fails, or is released. -+/// Register a callback that receives execution statistics after the scan stream -+/// is fully consumed to EOF. - /// --/// The callback and `callback_ctx` must remain valid until the scan stream is --/// finalized. Metric names and arrays passed to the callback are borrowed and --/// must be copied if the caller needs to retain them. The callback must be --/// thread-safe, must return normally without unwinding or throwing an --/// exception, and must not call `lance_scanner_*` with the originating scanner. -+/// The callback is not guaranteed to run if execution fails, the scan is -+/// cancelled, or the scanner / exported Arrow stream is released before EOF. -+/// The callback and `callback_ctx` must remain valid until the callback returns -+/// or, if it has not run, until the owning scan stream is released. Metric names -+/// and arrays passed to the callback are borrowed and must be copied if the -+/// caller needs to retain them. The callback must be thread-safe, must return -+/// normally without unwinding or throwing an exception, and must not call -+/// `lance_scanner_*` with the originating scanner. - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_set_statistics_callback( - scanner: *mut LanceScanner, -diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index db35fea..c1bb71d 100644 ---- a/tests/c_api_test.rs -+++ b/tests/c_api_test.rs -@@ -372,7 +372,43 @@ fn test_scanner_statistics_callback_with_next_multi_fragment() { - assert!(captured.requests > 0); - assert!(captured.metrics.iter().all(|(name, _, _)| !name.is_empty())); - -+ let mut batch = ptr::null_mut(); -+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, 1); -+ assert!(batch.is_null()); -+ assert_eq!(captured.calls, 1, "callback must run exactly once"); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_statistics_callback_not_called_on_early_scanner_close() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut batch = ptr::null_mut(); -+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, 0); -+ assert!(!batch.is_null()); -+ unsafe { lance_batch_free(batch) }; -+ - unsafe { lance_scanner_close(scanner) }; -+ assert_eq!(captured.calls, 0); - unsafe { lance_dataset_close(ds) }; - } - -@@ -398,9 +434,15 @@ fn test_scanner_statistics_callback_with_arrow_stream() { - ); - - let mut stream = FFI_ArrowArrayStream::empty(); -- assert_eq!(unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, 0); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0 -+ ); - let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream) }.unwrap(); -- assert_eq!(reader.map(|batch| batch.unwrap().num_rows()).sum::(), 5); -+ assert_eq!( -+ reader.map(|batch| batch.unwrap().num_rows()).sum::(), -+ 5 -+ ); - assert_eq!(captured.calls, 1); - assert!(captured.bytes_read > 0); - -@@ -408,6 +450,74 @@ fn test_scanner_statistics_callback_with_arrow_stream() { - unsafe { lance_dataset_close(ds) }; - } - -+#[test] -+fn test_scanner_statistics_callback_not_called_on_early_arrow_stream_release() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0 -+ ); -+ let mut reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream) }.unwrap(); -+ assert!(reader.next().unwrap().is_ok()); -+ drop(reader); -+ -+ assert_eq!(captured.calls, 0); -+ unsafe { lance_scanner_close(scanner) }; -+ assert_eq!(captured.calls, 0); -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_statistics_callback_not_called_on_materialization_error() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let bad_filter = c_str("NOT A VALID >>> FILTER ???"); -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), bad_filter.as_ptr()) }; -+ assert!(!scanner.is_null()); -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut batch = ptr::null_mut(); -+ assert_eq!(unsafe { lance_scanner_next(scanner, &mut batch) }, -1); -+ assert!(batch.is_null()); -+ assert_eq!(captured.calls, 0); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ assert_eq!(captured.calls, 0); -+ unsafe { lance_dataset_close(ds) }; -+} -+ - #[test] - fn test_scanner_statistics_callback_rejects_null_inputs() { - assert_eq!( -@@ -1367,6 +1477,17 @@ fn test_poll_next_basic() { - let c_uri = c_str(&uri_clone); - let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; - let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ let mut captured = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut captured as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); - - use std::sync::atomic::{AtomicBool, Ordering}; - static WOKE: AtomicBool = AtomicBool::new(false); -@@ -1401,6 +1522,15 @@ fn test_poll_next_basic() { - assert!(iterations < 1000, "poll loop should not spin forever"); - } - assert_eq!(total_rows, 5); -+ assert_eq!(captured.calls, 1); -+ -+ let mut batch: *mut LanceBatch = ptr::null_mut(); -+ assert_eq!( -+ unsafe { lance_scanner_poll_next(scanner, test_waker, ptr::null_mut(), &mut batch) }, -+ LancePollStatus::Finished -+ ); -+ assert!(batch.is_null()); -+ assert_eq!(captured.calls, 1, "callback must run exactly once"); - - unsafe { lance_scanner_close(scanner) }; - unsafe { lance_dataset_close(ds) }; - -From fa168ef99951d0396e50c060e540dee93e14e2be Mon Sep 17 00:00:00 2001 -From: zhangstar333 -Date: Mon, 24 Aug 2026 20:47:41 +0800 -Subject: [PATCH 3/3] update - ---- - include/lance/lance.h | 55 ++++++++--- - include/lance/lance.hpp | 22 ++++- - src/scanner.rs | 51 +++++++--- - tests/c_api_test.rs | 188 +++++++++++++++++++++++++++++++------ - tests/cpp/test_c_api.c | 39 +++++++- - tests/cpp/test_cpp_api.cpp | 28 +++++- - 6 files changed, 319 insertions(+), 64 deletions(-) - -diff --git a/include/lance/lance.h b/include/lance/lance.h -index 1a6822b..5b12f3d 100644 ---- a/include/lance/lance.h -+++ b/include/lance/lance.h -@@ -873,7 +873,8 @@ typedef enum { - * Borrowed view of one dynamically named scan metric. - * - * `name` is not NUL-terminated. `name` and this structure are valid only for -- * the duration of the LanceScanStatisticsCallback invocation. -+ * the duration of the LanceScanStatisticsCallback invocation. Metric order is -+ * unspecified. - */ - typedef struct { - const char* name; -@@ -905,11 +906,22 @@ typedef struct { - /** - * Receives scan statistics after a stream is fully consumed to EOF. - * -- * The statistics and all nested pointers are borrowed and valid only for the -- * duration of this call. The callback may run on the thread that observes EOF -- * and must therefore be thread-safe. It must return normally without throwing -- * an exception or unwinding, and must not call any `lance_scanner_*` function -- * with the originating scanner. -+ * `statistics` is non-NULL. It and all nested pointers are borrowed and valid -+ * only for the duration of this call. The callback may run on the thread that -+ * observes EOF and must therefore be thread-safe. It must return normally -+ * without throwing an exception or unwinding, and must not call any -+ * `lance_scanner_*` function with the originating scanner. -+ * -+ * From callback entry until the enclosing operation that observes EOF has -+ * returned to its caller, the callback must not directly or indirectly cause -+ * `get_schema`, `get_next`, `get_last_error`, or `release` to be called on any -+ * ArrowArrayStream derived from the originating scanner, nor cause such a -+ * stream to be moved, destroyed, or otherwise accessed. This includes signaling -+ * or scheduling another thread to act based only on callback completion: the -+ * callback returns before the enclosing stream operation does. Such interaction -+ * is reentrant and has undefined behavior. Normal access may resume only after -+ * the enclosing ArrowArrayStream `get_next`, `lance_scanner_next`, or -+ * `lance_scanner_poll_next` call returns to its caller. - * - * Scan statistics are diagnostic and best-effort. The callback must handle its - * own errors and must not use them to abort or throw across this FFI boundary. -@@ -925,15 +937,27 @@ typedef void (*LanceScanStatisticsCallback)( - * Must be called before starting the scan; registering after the scan starts - * returns an error. `callback` must not be NULL. `callback_ctx` may be NULL. A - * non-NULL `callback_ctx` must remain valid, and `callback` must remain valid, -- * until the callback returns or, if the callback has not run, until the owning -- * scan stream is released. For `lance_scanner_next` and -- * `lance_scanner_poll_next`, the scanner owns the stream. For an exported -- * ArrowArrayStream, the Arrow stream owns it independently of the scanner. -- * -- * The callback is invoked exactly once when the stream is fully consumed to -- * EOF. It is not guaranteed to run if execution fails, the scan is cancelled, -- * or the scanner / ArrowArrayStream is released before EOF. Replaces a -- * previously registered callback. -+ * until all of the following are true: the scanner is closed, every in-flight -+ * `lance_scanner_scan_async` call has delivered its completion callback, and -+ * every ArrowArrayStream derived from the scanner has been released. The -+ * registration remains installed after a callback returns and applies to -+ * streams created later from the same scanner. For `lance_scanner_next` and -+ * `lance_scanner_poll_next`, the scanner owns the stream. Exported and -+ * asynchronous ArrowArrayStreams own their registrations independently of the -+ * scanner and may invoke the callback after the scanner is closed. Concurrent -+ * streams may invoke the callback concurrently. -+ * -+ * The callback is invoked exactly once for each derived stream that is fully -+ * consumed to EOF. It is not guaranteed to run for a stream if execution fails, -+ * the scan is cancelled, or the scanner / ArrowArrayStream is released before -+ * EOF. Before scanning starts, a new registration replaces the previous one; -+ * after a successful replacement, the previous callback and context are no -+ * longer retained and may be retired. -+ * -+ * From callback entry until the enclosing EOF-observing operation returns, the -+ * callback must not directly or indirectly cause interaction with any -+ * ArrowArrayStream derived from this scanner; see LanceScanStatisticsCallback -+ * for the complete reentrancy restriction. - * - * @return 0 on success, -1 on error - */ -@@ -950,6 +974,7 @@ void lance_scanner_close(LanceScanner* scanner); - - /** - * Materialize the scan as an ArrowArrayStream (blocking). -+ * The scanner remains valid, and each call creates an independent stream. - * - * Reading the exported stream may surface a mid-iteration panic as one - * error through the Arrow C stream contract (nonzero get_next plus -diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp -index 4268801..8aa97e2 100644 ---- a/include/lance/lance.hpp -+++ b/include/lance/lance.hpp -@@ -1128,10 +1128,22 @@ class Scanner { - } - - /// Register a callback for scan statistics after successful full exhaustion. -- /// The callback is not guaranteed on error, cancellation, or early release. It -- /// may run on the thread that observes EOF, must be thread-safe, must not throw, -- /// and must not re-enter the originating scanner. The callback and a non-null -- /// context must remain valid until the callback returns or the stream is released. -+ /// The registration applies to every stream derived from this scanner, including -+ /// concurrent streams and streams created after an earlier callback returns. The -+ /// callback is not guaranteed on error, cancellation, or early release. It may -+ /// run on the thread that observes EOF, must be thread-safe, must not throw, and -+ /// must not re-enter the originating scanner. The callback and a non-null context -+ /// must remain valid until the scanner is closed, all async scan requests have -+ /// delivered their completion callbacks, and all derived streams are released. -+ /// From callback entry until the enclosing operation that observes EOF has -+ /// returned to its caller, the callback must not directly or indirectly cause -+ /// any ArrowArrayStream derived from this Scanner to be accessed, called, -+ /// released, moved, or destroyed. This includes signaling or scheduling another -+ /// thread to act based only on callback completion: the callback returns before -+ /// the enclosing stream operation does. Such interaction is reentrant and has -+ /// undefined behavior. Normal access may resume only after the enclosing -+ /// ArrowArrayStream `get_next`, `lance_scanner_next`, or -+ /// `lance_scanner_poll_next` call returns. - Scanner& statistics_callback(LanceScanStatisticsCallback callback, void* callback_ctx) { - if (lance_scanner_set_statistics_callback(handle_.get(), callback, callback_ctx) != 0) - check_error(); -@@ -1153,7 +1165,7 @@ class Scanner { - return index_segments(reinterpret_cast(uuids.data()), uuids.size()); - } - -- /// Materialize the scan as an ArrowArrayStream (blocking). -+ /// Materialize an independent ArrowArrayStream (blocking). The scanner remains valid. - void to_arrow_stream(ArrowArrayStream* out) { - if (lance_scanner_to_arrow_stream(handle_.get(), out) != 0) - check_error(); -diff --git a/src/scanner.rs b/src/scanner.rs -index 414c269..ef9d290 100644 ---- a/src/scanner.rs -+++ b/src/scanner.rs -@@ -305,7 +305,7 @@ pub enum LanceScanMetricKind { - /// Borrowed view of one dynamically named scan metric. - /// - /// `name` is not NUL-terminated. Both `name` and this structure are valid only --/// for the duration of the scan statistics callback. -+/// for the duration of the scan statistics callback. Metric order is unspecified. - #[repr(C)] - #[derive(Clone, Copy, Debug)] - pub struct LanceScanMetric { -@@ -318,8 +318,10 @@ pub struct LanceScanMetric { - /// Borrowed view of the execution statistics for one fully consumed scan. - /// - /// The fixed fields are stable summary metrics. `metrics` contains additional --/// implementation-specific counters and timings and is valid only for the --/// duration of the callback. -+/// implementation-specific counters and timings whose names are not a stable API -+/// and are intended only for diagnostics and profiles. Dynamic metrics are -+/// best-effort and may be omitted if they cannot be materialized. `metrics` is -+/// null when `metrics_len` is zero and is valid only for the callback duration. - #[repr(C)] - #[derive(Clone, Copy, Debug)] - pub struct LanceScanStatistics { -@@ -333,13 +335,20 @@ pub struct LanceScanStatistics { - pub metrics_len: usize, - } - --/// Callback invoked after a scan stream is fully consumed to EOF. -+/// Callback invoked once for each derived scan stream that is fully consumed to EOF. - /// - /// The callback is an FFI boundary and must return normally without unwinding - /// or throwing an exception. It must not call back into `lance_scanner_*` with --/// the originating scanner. -+/// the originating scanner. From callback entry until the enclosing operation -+/// that observes EOF has returned to its caller, the callback must not directly -+/// or indirectly cause any Arrow C stream derived from the originating scanner to -+/// be called, released, moved, destroyed, or otherwise accessed. This includes -+/// signaling or scheduling another thread to act based only on callback completion: -+/// the callback returns before the enclosing stream operation does. Such interaction -+/// is reentrant and has undefined behavior. Normal access may resume only after the -+/// enclosing `get_next`, `lance_scanner_next`, or `lance_scanner_poll_next` returns. - pub type LanceScanStatisticsCallback = -- Option; -+ Option; - - struct SendScanStatisticsCallback { - callback: unsafe extern "C" fn(*mut c_void, *const LanceScanStatistics), -@@ -347,7 +356,9 @@ struct SendScanStatisticsCallback { - } - - // SAFETY: The C API requires the callback and its context to remain valid and --// safe to invoke from the thread that observes the scan stream's EOF. -+// safe to invoke until the scanner is closed, every in-flight asynchronous scan -+// has delivered its completion callback, and every derived stream has been -+// released. Concurrent derived streams may invoke the callback concurrently. - unsafe impl Send for SendScanStatisticsCallback {} - unsafe impl Sync for SendScanStatisticsCallback {} - -@@ -657,12 +668,24 @@ unsafe fn scanner_set_substrait_filter_inner( - /// - /// The callback is not guaranteed to run if execution fails, the scan is - /// cancelled, or the scanner / exported Arrow stream is released before EOF. --/// The callback and `callback_ctx` must remain valid until the callback returns --/// or, if it has not run, until the owning scan stream is released. Metric names --/// and arrays passed to the callback are borrowed and must be copied if the --/// caller needs to retain them. The callback must be thread-safe, must return --/// normally without unwinding or throwing an exception, and must not call --/// `lance_scanner_*` with the originating scanner. -+/// The registration applies to every stream derived from this scanner, including -+/// streams created after an earlier callback has returned. The callback and -+/// `callback_ctx` must remain valid until the scanner is closed, every in-flight -+/// asynchronous scan has delivered its completion callback, and every derived -+/// stream has been released. -+/// Metric names and arrays passed to the callback are borrowed and must be copied -+/// if the caller needs to retain them. The callback must be thread-safe, must -+/// return normally without unwinding or throwing an exception, and must not call -+/// `lance_scanner_*` with the originating scanner. From callback entry until the -+/// enclosing operation that observes EOF has returned to its caller, the callback -+/// must not directly or indirectly cause any Arrow C stream derived from that -+/// scanner to be called, released, moved, destroyed, or otherwise accessed. This -+/// includes signaling or scheduling another thread to act based only on callback -+/// completion: the callback returns before the enclosing stream operation does. -+/// Such interaction is reentrant and has undefined behavior. Normal access may -+/// resume only after the enclosing `get_next`, `lance_scanner_next`, or -+/// `lance_scanner_poll_next` returns. Replacing the registration before scanning -+/// starts immediately releases the previous registration. - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_set_statistics_callback( - scanner: *mut LanceScanner, -@@ -732,7 +755,7 @@ pub unsafe extern "C" fn lance_scanner_close(scanner: *mut LanceScanner) { - /// Materialize the scan as an Arrow C Data Interface `ArrowArrayStream`. - /// - /// This is the preferred API for simple integrations — blocks the calling thread. --/// The scanner is consumed by this call and should not be used afterward (close it). -+/// The scanner remains valid and may be used to create additional streams. - /// - /// The exported stream is panic-guarded (issue #61): a panic during export - /// poisons the scanner — this call returns -1 with `LANCE_ERR_PANIC`, and -diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index c1bb71d..daa7425 100644 ---- a/tests/c_api_test.rs -+++ b/tests/c_api_test.rs -@@ -10,6 +10,7 @@ use std::ffi::{CString, c_char, c_void}; - use std::process::Command; - use std::ptr; - use std::sync::Arc; -+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}; - - use arrow::ffi::from_ffi; - use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; -@@ -151,6 +152,29 @@ unsafe extern "C" fn capture_scan_statistics( - .collect(); - } - -+#[derive(Default)] -+struct AtomicScanStatisticsCapture { -+ calls: AtomicUsize, -+ invalid_statistics: AtomicBool, -+} -+ -+unsafe extern "C" fn capture_scan_statistics_atomically( -+ callback_ctx: *mut c_void, -+ statistics: *const LanceScanStatistics, -+) { -+ if callback_ctx.is_null() { -+ return; -+ } -+ let captured = unsafe { &*callback_ctx.cast::() }; -+ if statistics.is_null() { -+ captured -+ .invalid_statistics -+ .store(true, AtomicOrdering::SeqCst); -+ return; -+ } -+ captured.calls.fetch_add(1, AtomicOrdering::SeqCst); -+} -+ - /// Helper: build a tiny dataset whose `value` column is nullable AND contains - /// at least one NULL. Used by tests that need to exercise upstream's - /// nullability-tightening pre-scan failure path. -@@ -413,7 +437,7 @@ fn test_scanner_statistics_callback_not_called_on_early_scanner_close() { - } - - #[test] --fn test_scanner_statistics_callback_with_arrow_stream() { -+fn test_scanner_statistics_callback_applies_to_reused_scanner() { - let (_tmp, uri) = create_test_dataset(); - let c_uri = c_str(&uri); - let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -@@ -433,20 +457,87 @@ fn test_scanner_statistics_callback_with_arrow_stream() { - 0 - ); - -- let mut stream = FFI_ArrowArrayStream::empty(); -+ let mut first_stream = FFI_ArrowArrayStream::empty(); - assert_eq!( -- unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut first_stream) }, - 0 - ); -- let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream) }.unwrap(); -+ let first_reader = unsafe { ArrowArrayStreamReader::from_raw(&mut first_stream) }.unwrap(); - assert_eq!( -- reader.map(|batch| batch.unwrap().num_rows()).sum::(), -+ first_reader -+ .map(|batch| batch.unwrap().num_rows()) -+ .sum::(), - 5 - ); - assert_eq!(captured.calls, 1); -- assert!(captured.bytes_read > 0); - -+ let mut second_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut second_stream) }, -+ 0 -+ ); - unsafe { lance_scanner_close(scanner) }; -+ -+ let second_reader = unsafe { ArrowArrayStreamReader::from_raw(&mut second_stream) }.unwrap(); -+ assert_eq!( -+ second_reader -+ .map(|batch| batch.unwrap().num_rows()) -+ .sum::(), -+ 5 -+ ); -+ assert_eq!(captured.calls, 2); -+ -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_statistics_callback_supports_concurrent_exported_streams() { -+ struct SendableArrowStream(FFI_ArrowArrayStream); -+ unsafe impl Send for SendableArrowStream {} -+ -+ fn consume_stream(mut stream: SendableArrowStream) -> usize { -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream.0) }.unwrap(); -+ reader.map(|batch| batch.unwrap().num_rows()).sum() -+ } -+ -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ let captured = Arc::new(AtomicScanStatisticsCapture::default()); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics_atomically), -+ Arc::as_ptr(&captured).cast_mut().cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut first_stream = FFI_ArrowArrayStream::empty(); -+ let mut second_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut first_stream) }, -+ 0 -+ ); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut second_stream) }, -+ 0 -+ ); -+ unsafe { lance_scanner_close(scanner) }; -+ -+ let first = std::thread::spawn(move || consume_stream(SendableArrowStream(first_stream))); -+ let second = std::thread::spawn(move || consume_stream(SendableArrowStream(second_stream))); -+ assert_eq!(first.join().unwrap(), 5); -+ assert_eq!(second.join().unwrap(), 5); -+ assert_eq!(captured.calls.load(AtomicOrdering::SeqCst), 2); -+ assert!(!captured.invalid_statistics.load(AtomicOrdering::SeqCst)); -+ - unsafe { lance_dataset_close(ds) }; - } - -@@ -546,6 +637,55 @@ fn test_scanner_statistics_callback_rejects_null_inputs() { - unsafe { lance_dataset_close(ds) }; - } - -+#[test] -+fn test_scanner_statistics_callback_replaces_registration_before_scan() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ -+ let mut replaced = CapturedScanStatistics::default(); -+ let mut active = CapturedScanStatistics::default(); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut replaced as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics), -+ (&mut active as *mut CapturedScanStatistics).cast(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0 -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream) }.unwrap(); -+ assert_eq!( -+ reader.map(|batch| batch.unwrap().num_rows()).sum::(), -+ 5 -+ ); -+ assert_eq!(replaced.calls, 0); -+ assert_eq!(active.calls, 1); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ - #[test] - fn test_scanner_statistics_callback_rejects_registration_after_scan_started() { - let (_tmp, uri) = create_test_dataset(); -@@ -814,6 +954,17 @@ fn test_scanner_scan_async() { - - let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; - assert!(!scanner.is_null()); -+ let captured = Arc::new(AtomicScanStatisticsCapture::default()); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_statistics_callback( -+ scanner, -+ Some(capture_scan_statistics_atomically), -+ Arc::as_ptr(&captured).cast_mut().cast(), -+ ) -+ }, -+ 0 -+ ); - - // Synchronization primitive for the async callback. - struct CallbackResult { -@@ -845,6 +996,7 @@ fn test_scanner_scan_async() { - on_complete, - Arc::as_ptr(&pair_clone) as *mut std::ffi::c_void, - ); -+ lance_scanner_close(scanner); - } - - // Wait for callback. -@@ -861,8 +1013,9 @@ fn test_scanner_scan_async() { - let reader = unsafe { ArrowArrayStreamReader::from_raw(ffi_stream) }.unwrap(); - let total_rows: usize = reader.map(|r| r.unwrap().num_rows()).sum(); - assert_eq!(total_rows, 5); -+ assert_eq!(captured.calls.load(AtomicOrdering::SeqCst), 1); -+ assert!(!captured.invalid_statistics.load(AtomicOrdering::SeqCst)); - -- unsafe { lance_scanner_close(scanner) }; - unsafe { lance_dataset_close(ds) }; - } - -@@ -1477,18 +1630,6 @@ fn test_poll_next_basic() { - let c_uri = c_str(&uri_clone); - let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; - let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -- let mut captured = CapturedScanStatistics::default(); -- assert_eq!( -- unsafe { -- lance_scanner_set_statistics_callback( -- scanner, -- Some(capture_scan_statistics), -- (&mut captured as *mut CapturedScanStatistics).cast(), -- ) -- }, -- 0 -- ); -- - use std::sync::atomic::{AtomicBool, Ordering}; - static WOKE: AtomicBool = AtomicBool::new(false); - unsafe extern "C" fn test_waker(_ctx: *mut std::ffi::c_void) { -@@ -1522,15 +1663,6 @@ fn test_poll_next_basic() { - assert!(iterations < 1000, "poll loop should not spin forever"); - } - assert_eq!(total_rows, 5); -- assert_eq!(captured.calls, 1); -- -- let mut batch: *mut LanceBatch = ptr::null_mut(); -- assert_eq!( -- unsafe { lance_scanner_poll_next(scanner, test_waker, ptr::null_mut(), &mut batch) }, -- LancePollStatus::Finished -- ); -- assert!(batch.is_null()); -- assert_eq!(captured.calls, 1, "callback must run exactly once"); - - unsafe { lance_scanner_close(scanner) }; - unsafe { lance_dataset_close(ds) }; -diff --git a/tests/cpp/test_c_api.c b/tests/cpp/test_c_api.c -index b3b78f0..4499cb9 100644 ---- a/tests/cpp/test_c_api.c -+++ b/tests/cpp/test_c_api.c -@@ -39,6 +39,36 @@ - } \ - } while (0) - -+typedef struct { -+ uint64_t calls; -+ uint64_t bytes_read; -+ int invalid; -+} ScanStatisticsCapture; -+ -+static void capture_scan_statistics( -+ void *callback_ctx, -+ const LanceScanStatistics *statistics -+) { -+ if (callback_ctx == NULL) return; -+ ScanStatisticsCapture *captured = (ScanStatisticsCapture *)callback_ctx; -+ if (statistics == NULL || -+ (statistics->metrics_len > 0 && statistics->metrics == NULL)) { -+ captured->invalid = 1; -+ return; -+ } -+ for (size_t i = 0; i < statistics->metrics_len; ++i) { -+ const LanceScanMetric *metric = &statistics->metrics[i]; -+ if ((metric->name_len > 0 && metric->name == NULL) || -+ (metric->kind != LANCE_SCAN_METRIC_COUNT && -+ metric->kind != LANCE_SCAN_METRIC_TIME_NANOSECONDS)) { -+ captured->invalid = 1; -+ return; -+ } -+ } -+ captured->calls += 1; -+ captured->bytes_read = statistics->bytes_read; -+} -+ - static void test_open_and_metadata(const char *uri) { - printf(" test_open_and_metadata... "); - -@@ -84,10 +114,14 @@ static void test_scan(const char *uri) { - /* Full scan via ArrowArrayStream */ - LanceScanner *scanner = lance_scanner_new(ds, NULL, NULL); - ASSERT(scanner != NULL, "scanner creation failed"); -+ ScanStatisticsCapture captured = {0}; -+ int32_t rc = lance_scanner_set_statistics_callback( -+ scanner, capture_scan_statistics, &captured); -+ ASSERT(rc == 0, "statistics callback registration failed"); - - struct ArrowArrayStream stream; - memset(&stream, 0, sizeof(stream)); -- int32_t rc = lance_scanner_to_arrow_stream(scanner, &stream); -+ rc = lance_scanner_to_arrow_stream(scanner, &stream); - ASSERT(rc == 0, "to_arrow_stream failed"); - - /* Read schema from stream */ -@@ -113,6 +147,9 @@ static void test_scan(const char *uri) { - } - - ASSERT(total_rows == expected_rows, "row count mismatch"); -+ ASSERT(captured.calls == 1, "statistics callback count mismatch"); -+ ASSERT(captured.bytes_read > 0, "statistics should report bytes read"); -+ ASSERT(captured.invalid == 0, "statistics callback received invalid data"); - printf("rows=%llu... ", (unsigned long long)total_rows); - - if (stream.release) stream.release(&stream); -diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp -index f8ae701..3293bfb 100644 ---- a/tests/cpp/test_cpp_api.cpp -+++ b/tests/cpp/test_cpp_api.cpp -@@ -25,6 +25,25 @@ - #define TEST(name) printf(" %s... ", #name) - #define PASS() printf("OK\n") - -+struct ScanStatisticsCapture { -+ uint64_t calls = 0; -+ uint64_t bytes_read = 0; -+ bool invalid = false; -+}; -+ -+static void capture_scan_statistics( -+ void* callback_ctx, -+ const LanceScanStatistics* statistics) noexcept { -+ if (!callback_ctx) return; -+ auto* captured = static_cast(callback_ctx); -+ if (!statistics || (statistics->metrics_len > 0 && !statistics->metrics)) { -+ captured->invalid = true; -+ return; -+ } -+ captured->calls += 1; -+ captured->bytes_read = statistics->bytes_read; -+} -+ - static void test_dataset_open(const std::string& uri) { - TEST(test_dataset_open); - -@@ -70,7 +89,11 @@ static void test_scanner_fluent(const std::string& uri) { - - // Fluent builder pattern. - auto scanner = ds.scan(); -- scanner.limit(5).offset(0).batch_size(2); -+ ScanStatisticsCapture captured; -+ scanner.limit(5) -+ .offset(0) -+ .batch_size(2) -+ .statistics_callback(capture_scan_statistics, &captured); - - ArrowArrayStream stream; - memset(&stream, 0, sizeof(stream)); -@@ -89,6 +112,9 @@ static void test_scanner_fluent(const std::string& uri) { - } - - assert(total == 5); -+ assert(captured.calls == 1); -+ assert(captured.bytes_read > 0); -+ assert(!captured.invalid); - printf("rows=%llu... ", (unsigned long long)total); - - if (stream.release) stream.release(&stream); diff --git a/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch b/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch deleted file mode 100644 index 7351f448135d23..00000000000000 --- a/thirdparty/patches/lance-c-0.1.7-runtime-filter.patch +++ /dev/null @@ -1,5138 +0,0 @@ -diff --git a/AGENTS.md b/AGENTS.md -index a1c0041..f236494 100644 ---- a/AGENTS.md -+++ b/AGENTS.md -@@ -2,8 +2,8 @@ - - ## Structure - Rust FFI source: `src/` --C header (stable ABI): `include/lance.h` --C++ RAII wrappers (header-only): `include/lance.hpp` -+C header (stable ABI): `include/lance/lance.h` -+C++ RAII wrappers (header-only): `include/lance/lance.hpp` - Tests (Rust): `tests/c_api_test.rs` - Tests (C/C++): `tests/cpp/` - Historical test data: `test_data/` -@@ -19,7 +19,8 @@ test C/C++ compilation: `cargo test --test compile_and_run_test -- --ignored` - - Opaque handles with `lance_*_open`/`lance_*_close` lifecycle. - - Thread-local error handling via `ffi_try!` macro. - - Arrow C Data Interface for zero-copy data exchange. --- `panic = "abort"` in release to prevent unwinding across FFI. -+- `panic = "unwind"` is required so guarded FFI boundaries can translate -+ panics to `LANCE_ERR_PANIC`; `panic = "abort"` builds are rejected. - - ## Coding Standards - -diff --git a/Cargo.toml b/Cargo.toml -index 5bc30bd..b5ba59a 100644 ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -25,6 +25,8 @@ lance-index = { git = "https://github.com/lance-format/lance.git", rev = "e934cc - lance-io = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } - lance-linalg = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } - lance-table = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } -+lance-datafusion = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c", features = ["substrait"] } -+datafusion = { version = "54.0.0", default-features = false } - arrow = { version = "58.0.0", features = ["prettyprint", "ffi"] } - arrow-array = "58.0.0" - arrow-schema = "58.0.0" -@@ -44,10 +46,8 @@ uuid = { version = "1", features = ["v4"] } - - [dev-dependencies] - lance = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c", features = ["substrait"] } --lance-datafusion = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c", features = ["substrait"] } - lance-datagen = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } - lance-file = { git = "https://github.com/lance-format/lance.git", rev = "e934cc2c" } --datafusion = { version = "54.0.0", default-features = false } - tokio = { version = "1", features = ["rt-multi-thread", "macros"] } - arrow-array = "58.0.0" - arrow-schema = "58.0.0" -diff --git a/README.md b/README.md -index 436c2f1..d6ae6c8 100644 ---- a/README.md -+++ b/README.md -@@ -67,7 +67,7 @@ Based on the [liblance RFC](https://github.com/lance-format/lance/discussions/60 - |--------|-----------|-------------| - | [x] | Async scan | Callback-based `lance_scanner_scan_async()` for non-blocking scans | - | [x] | Dataset metadata | `lance_dataset_version()`, `lance_dataset_count_rows()`, `lance_dataset_latest_version()` | --| [x] | Substrait filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression` (preferred over SQL strings for query engines) | -+| [x] | Filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression`; `lance_scanner_additional_sql_filter()` adds SQL predicates with AND before scanning starts | - - ## Building - -diff --git a/include/lance/lance.h b/include/lance/lance.h -index 5b12f3d..ccd7e8c 100644 ---- a/include/lance/lance.h -+++ b/include/lance/lance.h -@@ -7,10 +7,15 @@ - * - * All data crosses this boundary via the Arrow C Data Interface - * (ArrowSchema, ArrowArray, ArrowArrayStream). -+ * For Arrow structures written to caller-provided output storage, the caller -+ * retains ownership of the outer structure and must invoke its non-NULL -+ * `release` callback exactly once to release the contents. APIs that allocate -+ * the outer structure as well document a separate matching free function. - * -- * Error handling uses thread-local storage: after any function returns -- * NULL (pointer) or -1 (int), call lance_last_error_code() and -- * lance_last_error_message() to get details. -+ * Error handling uses thread-local storage: after any function returns its -+ * documented error sentinel (for example NULL, -1, or 0 for selected scalar -+ * accessors), call lance_last_error_code() and lance_last_error_message() to -+ * get details. - */ - - #ifndef LANCE_H -@@ -100,16 +105,21 @@ typedef enum { - * Honest limits: a double panic, a panic in a destructor while unwinding, a - * stack overflow, or an allocation failure still aborts the process. A - * panic caught inside a close/free call (lance_*_close, lance_batch_free, -- * lance_free_string, or the release callback of an exported -- * ArrowArrayStream) is logged and the remainder of the value may leak — -- * close is best-effort by design. Post-panic process state is best-effort: -- * hosts should fail the in-flight query rather than retry a poisoned -- * handle. -+ * lance_free_string, lance_scanner_async_stream_free, or the release callback -+ * of an exported ArrowArrayStream) is logged and the remainder of the value -+ * may leak — close is best-effort by design. Post-panic process state is -+ * best-effort: hosts should fail the in-flight query rather than retry a -+ * poisoned handle. - * -- * Callbacks passed INTO the library (LanceCallback, LanceWaker) are the -- * reverse direction and are NOT covered by this contract: their ABI is -- * non-unwinding, so a panicking callback aborts the host process before -- * the library can contain it. Callbacks must not panic. -+ * Callbacks passed INTO the library (LanceCallback, LanceWaker, and -+ * LanceScanStatisticsCallback) are the reverse direction and are NOT covered -+ * by this contract: their ABI is non-unwinding, so a callback that throws or -+ * unwinds can abort the host process before the library can contain it. -+ * Callbacks must return normally. -+ * -+ * This contract requires Rust's `panic = "unwind"` strategy. The crate -+ * rejects `panic = "abort"` builds at compile time because catch_unwind -+ * cannot provide this API contract in such a build. - */ - - /* ─── Index types (Phase 2) ─── */ -@@ -175,6 +185,7 @@ typedef struct LanceVersions LanceVersions; - typedef struct LanceDataStatistics LanceDataStatistics; - typedef struct LanceIndexSegmentBuilder LanceIndexSegmentBuilder; - typedef struct LanceIndexSegmentMetadata LanceIndexSegmentMetadata; -+typedef struct LanceFtsQueryContext LanceFtsQueryContext; - - /* ─── Dataset lifecycle ─── */ - -@@ -202,13 +213,22 @@ void lance_dataset_close(LanceDataset* dataset); - - /* ─── Dataset metadata (sync, in-memory) ─── */ - --/** Return the version number of this dataset snapshot. */ -+/** -+ * Return the version number of this dataset snapshot. -+ * @return version on success, or 0 on error (check lance_last_error_code()) -+ */ - uint64_t lance_dataset_version(const LanceDataset* dataset); - --/** Return the number of rows. Returns 0 on error. */ -+/** -+ * Return the number of rows. Returns 0 on error; an empty dataset also returns -+ * 0, so check lance_last_error_code(). -+ */ - uint64_t lance_dataset_count_rows(const LanceDataset* dataset); - --/** Return the latest version ID (I/O). Returns 0 on error. */ -+/** -+ * Return the latest version ID (I/O), or 0 on error (check -+ * lance_last_error_code()). -+ */ - uint64_t lance_dataset_latest_version(const LanceDataset* dataset); - - /* ─── Version history ─── */ -@@ -220,7 +240,10 @@ uint64_t lance_dataset_latest_version(const LanceDataset* dataset); - */ - LanceVersions* lance_dataset_versions(const LanceDataset* dataset); - --/** Number of versions in the snapshot. Returns 0 on error. */ -+/** -+ * Number of versions in the snapshot, or 0 on error (check -+ * lance_last_error_code()). -+ */ - uint64_t lance_versions_count(const LanceVersions* versions); - - /** -@@ -755,7 +778,10 @@ int32_t lance_dataset_schema( - - /* ─── Fragment enumeration ─── */ - --/** Return the number of fragments in the dataset. Returns 0 on error. */ -+/** -+ * Return the number of fragments in the dataset. Returns 0 on error; a -+ * dataset with no fragments also returns 0, so check lance_last_error_code(). -+ */ - uint64_t lance_dataset_fragment_count(const LanceDataset* dataset); - - /** -@@ -769,6 +795,15 @@ int32_t lance_dataset_fragment_ids(const LanceDataset* dataset, uint64_t* out_id - - /** - * Take rows by indices. -+ * -+ * On success, `out` is initialized in caller-owned storage; the caller must -+ * eventually invoke its non-NULL `release` callback exactly once. The schema -+ * is validated before the stream callbacks are exposed. A deferred iteration -+ * failure, including a caught panic in `get_next`, is reported through the -+ * Arrow C stream contract (nonzero `get_next` plus `get_last_error`). A panic -+ * during `release` cleanup is contained and logged; cleanup remains -+ * best-effort. -+ * - * @param indices Array of 0-based row offsets - * @param num_indices Length of indices array - * @param columns NULL-terminated column names, or NULL for all -@@ -791,6 +826,14 @@ int32_t lance_dataset_take( - * Missing or deleted row IDs may be omitted from the result. For found rows, - * input order and duplicates are preserved. - * -+ * On success, `out` is initialized in caller-owned storage; the caller must -+ * eventually invoke its non-NULL `release` callback exactly once. The schema -+ * is validated before the stream callbacks are exposed. A deferred iteration -+ * failure, including a caught panic in `get_next`, is reported through the -+ * Arrow C stream contract (nonzero `get_next` plus `get_last_error`). A panic -+ * during `release` cleanup is contained and logged; cleanup remains -+ * best-effort. -+ * - * @param dataset Open dataset snapshot. - * @param row_ids Array of dataset row IDs. May be NULL only when - * `num_row_ids` is zero. -@@ -863,6 +906,22 @@ int32_t lance_scanner_set_substrait_filter( - size_t len - ); - -+/** -+ * Add an SQL filter that is combined with the selected primary filter using -+ * AND. The primary filter is the Substrait filter when set, otherwise it is -+ * the SQL filter passed to `lance_scanner_new`. Multiple additional SQL -+ * filters are also combined using AND. -+ * -+ * Must be called before the scan starts. The filter string is copied. -+ * -+ * @param filter Non-NULL, non-empty SQL filter expression -+ * @return 0 on success, -1 on error -+ */ -+int32_t lance_scanner_additional_sql_filter( -+ LanceScanner* scanner, -+ const char* filter -+); -+ - /** Type of a dynamically named scan metric. */ - typedef enum { - LANCE_SCAN_METRIC_COUNT = 0, -@@ -967,7 +1026,16 @@ int32_t lance_scanner_set_statistics_callback( - void* callback_ctx - ); - --/** Close and free a scanner handle. */ -+/** -+ * Close and free a scanner handle. Safe to call with NULL; a non-NULL handle -+ * must be closed exactly once. -+ * -+ * This is the retirement boundary for poll wakers registered by -+ * lance_scanner_poll_next(): it cancels callbacks that have not entered and -+ * waits for any callback already in progress to return before freeing the -+ * scanner. Do not call this function from one of the scanner's own waker -+ * callbacks, because close must wait for that callback to return. -+ */ - void lance_scanner_close(LanceScanner* scanner); - - /* ─── Sync scan: ArrowArrayStream ─── */ -@@ -975,6 +1043,10 @@ void lance_scanner_close(LanceScanner* scanner); - /** - * Materialize the scan as an ArrowArrayStream (blocking). - * The scanner remains valid, and each call creates an independent stream. -+ * `out` points to caller-owned storage. On success, the caller must eventually -+ * invoke `out->release(out)` exactly once when `release` is non-NULL; that -+ * releases the stream contents but not the caller-owned outer structure. Do -+ * not pass this caller-allocated stream to lance_scanner_async_stream_free(). - * - * Reading the exported stream may surface a mid-iteration panic as one - * error through the Arrow C stream contract (nonzero get_next plus -@@ -1005,14 +1077,18 @@ int32_t lance_scanner_next( - /** - * Callback type for async operations. - * -- * The callback runs on the dispatcher thread; on failure the error code and -- * message are installed in that thread's thread-local storage immediately -- * before the callback runs, so lance_last_error_* called from inside the -- * callback observes this completion's failure. -+ * The callback normally runs on the dedicated dispatcher thread. During a -+ * rare dispatcher startup or delivery failure, completion falls back to the -+ * thread that detects the failure (for example the calling or producing -+ * thread), so the callback must be thread-safe. On failure the error code and -+ * message are installed on the actual callback thread immediately before the -+ * callback runs, so lance_last_error_* called from inside the callback -+ * observes this completion's failure. - * -- * Callbacks must not panic: the callback ABI is non-unwinding, so a -- * panicking callback aborts the host process before the dispatcher can -- * contain it. -+ * Callbacks must return normally: the callback ABI is non-unwinding, so a -+ * callback that throws or unwinds can abort the host process before the -+ * dispatcher can contain it. -+ * A callback passed to lance_scanner_scan_async() must not be NULL. - * - * @param ctx Opaque pointer passed back from the caller - * @param status 0 = success, -1 = error -@@ -1021,14 +1097,28 @@ int32_t lance_scanner_next( - typedef void (*LanceCallback)(void* ctx, int32_t status, void* result); - - /** -- * Start an async scan. The callback fires on a dedicated dispatcher thread -- * when the ArrowArrayStream is ready. -+ * Start an async scan. The callback normally fires on a dedicated dispatcher -+ * thread when the ArrowArrayStream is ready. During a rare dispatcher -+ * infrastructure failure it may instead run on the calling or producing -+ * thread, so it must be thread-safe. -+ * -+ * For a non-NULL callback, exactly one completion is delivered, including for -+ * validation, setup, task, and dispatcher failures. The fallback path may -+ * invoke it before lance_scanner_scan_async() returns. `callback` and a -+ * non-NULL `callback_ctx` must remain valid until that invocation returns. -+ * -+ * `callback` must not be NULL; `callback_ctx` may be NULL. On success, result -+ * is a library-allocated ArrowArrayStream owned by the caller. The caller must -+ * eventually pass it exactly once to lance_scanner_async_stream_free(), even -+ * if it has already invoked the stream's release callback directly. Do not -+ * free the returned outer structure with free(), delete, or a platform -+ * allocator. - * - * On failure the callback receives status -1 with result NULL, and the -- * error code/message are installed in the dispatcher thread's thread-local -- * storage immediately before the callback runs (per completion). A panic in -- * the scan task also yields status -1 with LANCE_ERR_PANIC and poisons the -- * scanner handle. -+ * error code/message are installed in the actual callback thread's -+ * thread-local storage immediately before the callback runs (per completion). -+ * A panic in the scan task also yields status -1 with LANCE_ERR_PANIC and -+ * poisons the scanner handle. - */ - void lance_scanner_scan_async( - const LanceScanner* scanner, -@@ -1036,6 +1126,19 @@ void lance_scanner_scan_async( - void* callback_ctx - ); - -+/** -+ * Release and free an ArrowArrayStream returned by a successful -+ * lance_scanner_scan_async() callback. -+ * -+ * If `stream->release` is non-NULL, this function invokes it before freeing -+ * the library-allocated outer structure. It is therefore valid both before -+ * and after a consumer has directly released the stream contents. `stream` -+ * may be NULL. A non-NULL pointer must be passed exactly once and must be the -+ * pointer delivered by lance_scanner_scan_async(); using this function for a -+ * caller-allocated ArrowArrayStream is invalid. -+ */ -+void lance_scanner_async_stream_free(struct ArrowArrayStream* stream); -+ - /* ─── Poll-based scan (for cooperative async runtimes) ─── */ - - typedef enum { -@@ -1045,12 +1148,26 @@ typedef enum { - LANCE_POLL_ERROR = -1, - } LancePollStatus; - --/** Waker callback: called from a Tokio thread when data is ready. */ -+/** -+ * Waker callback: called from a Tokio thread when data is ready. A waker -+ * passed to lance_scanner_poll_next() must not be NULL. For one poll call -+ * that returns LANCE_POLL_PENDING, all internal RawWaker clones share a -+ * one-shot gate, so the callback fires at most once. -+ * -+ * The callback and `ctx` must be thread-safe and must remain valid until the -+ * callback returns or lance_scanner_close() returns. Close cancels a pending -+ * callback and waits for an active callback before returning, so the caller -+ * may destroy `ctx` afterwards. The callback must return normally and must -+ * not call lance_scanner_close() or otherwise re-enter its originating -+ * scanner. -+ */ - typedef void (*LanceWaker)(void* ctx); - - /** - * Poll for the next batch without blocking. -- * See RFC for usage pattern. -+ * `waker` must not be NULL; `waker_ctx` may be NULL. `out` is set to a -+ * LanceBatch only for LANCE_POLL_READY and is set to NULL for -+ * LANCE_POLL_PENDING, LANCE_POLL_FINISHED, and LANCE_POLL_ERROR. - */ - LancePollStatus lance_scanner_poll_next( - LanceScanner* scanner, -@@ -1329,7 +1446,10 @@ const char* lance_index_segment_metadata_name( - const LanceIndexSegmentMetadata* metadata - ); - --/** Return the dataset version against which the segment was built. */ -+/** -+ * Return the dataset version against which the segment was built, or 0 on -+ * error (check lance_last_error_code()). -+ */ - uint64_t lance_index_segment_metadata_dataset_version( - const LanceIndexSegmentMetadata* metadata - ); -@@ -1355,7 +1475,10 @@ const char* lance_index_segment_metadata_index_details_type_url( - const LanceIndexSegmentMetadata* metadata - ); - --/** Return the number of indexed field IDs. */ -+/** -+ * Return the number of indexed field IDs. Returns 0 on error; zero may also be -+ * a valid count, so check lance_last_error_code(). -+ */ - size_t lance_index_segment_metadata_field_count( - const LanceIndexSegmentMetadata* metadata - ); -@@ -1368,7 +1491,10 @@ int32_t lance_index_segment_metadata_field_ids( - size_t* out_count - ); - --/** Return the number of fragment IDs covered by the segment. */ -+/** -+ * Return the number of fragment IDs covered by the segment. Returns 0 on -+ * error; zero may also be a valid count, so check lance_last_error_code(). -+ */ - size_t lance_index_segment_metadata_fragment_count( - const LanceIndexSegmentMetadata* metadata - ); -@@ -1393,7 +1519,11 @@ void lance_index_segment_metadata_free(LanceIndexSegmentMetadata* metadata); - /** Drop an index by name. Returns -1 (NOT_FOUND) if no such index. */ - int32_t lance_dataset_drop_index(LanceDataset* dataset, const char* name); - --/** Number of user indexes (excludes system indexes). Returns 0 on error. */ -+/** -+ * Number of user indexes (excludes system indexes). Returns 0 on error; a -+ * dataset with no user indexes also returns 0, so check -+ * lance_last_error_code(). -+ */ - uint64_t lance_dataset_index_count(const LanceDataset* dataset); - - /** -@@ -1489,6 +1619,56 @@ int32_t lance_scanner_set_index_segments( - - /* ─── Full-text search (Phase 2) ─── */ - -+/** -+ * Required relationship between a pinned dataset snapshot and its committed -+ * FTS index segments. Values are ABI-stable; API parameters use int32_t. -+ */ -+typedef enum { -+ /** Fail prepare if any current fragment is not covered by the FTS index. */ -+ LANCE_FTS_COVERAGE_STRICT = 0, -+ /** Score and search only rows covered by committed FTS index segments. */ -+ LANCE_FTS_COVERAGE_INDEX_ONLY = 1, -+} LanceFtsCoverageMode; -+ -+/** -+ * Prepare an immutable, process-local FTS query context for one column. -+ * -+ * Preparation pins the dataset handle's current snapshot, enumerates all -+ * committed FTS segments for `column`, checks fragment coverage, opens those -+ * segments, and computes one query-specific global BM25 scorer across their -+ * indexed documents. The context can then be shared by any number of scanners -+ * created from the exact same process-local dataset snapshot. It has no -+ * serialization or cross-process transport format. Reopening the same URI and -+ * manifest version creates a different identity and cannot reuse the context, -+ * because storage options and object-store endpoints may differ. -+ * -+ * In LANCE_FTS_COVERAGE_INDEX_ONLY mode, unindexed fragments are allowed and -+ * excluded from both the scorer corpus and query results. In STRICT mode any -+ * unindexed fragment makes this call fail. -+ * -+ * Prepared contexts currently support exact Match queries only. -+ * `max_fuzzy_distance` must be zero because fuzzy execution requires its -+ * canonical expanded vocabulary to be prepared together with the scorer. -+ * This restriction does not apply to lance_scanner_full_text_search(). -+ * -+ * @param max_fuzzy_distance Must be zero for prepared query contexts. -+ * @param coverage_mode Fixed-width LanceFtsCoverageMode discriminant. -+ * @return Context handle on success, or NULL on error. -+ */ -+LanceFtsQueryContext* lance_dataset_prepare_fts_query( -+ const LanceDataset* dataset, -+ const char* column, -+ const char* query, -+ uint32_t max_fuzzy_distance, -+ int32_t coverage_mode -+); -+ -+/** -+ * Close a context handle. NULL-safe. Scanners that already attached this -+ * context retain shared ownership and remain valid. -+ */ -+void lance_fts_query_context_close(LanceFtsQueryContext* context); -+ - /** - * Set a BM25 full-text search query on the scanner. - * -@@ -1508,6 +1688,30 @@ int32_t lance_scanner_full_text_search( - uint32_t max_fuzzy_distance - ); - -+/** -+ * Attach a prepared process-local FTS query context. The scanner must have -+ * been created from the exact LanceDataset snapshot used to prepare the -+ * context; URI and manifest version equality is not sufficient. The scanner -+ * retains shared ownership, so the caller may close `context` after success. -+ * This is mutually exclusive with nearest and lance_scanner_full_text_search -+ * because the context already owns the FTS query. -+ */ -+int32_t lance_scanner_set_fts_query_context( -+ LanceScanner* scanner, -+ const LanceFtsQueryContext* context -+); -+ -+/** -+ * Restrict a context-backed FTS scan to `len` context segment UUIDs supplied -+ * by the caller's planner. Pass `len == 0` to clear the restriction and search -+ * all context segments. Duplicate or unknown UUIDs are rejected. -+ */ -+int32_t lance_scanner_set_fts_index_segments( -+ LanceScanner* scanner, -+ const uint8_t* segment_uuids, -+ size_t len -+); -+ - /* ─── Dataset writer ─── */ - - /** -diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp -index 8aa97e2..330e419 100644 ---- a/include/lance/lance.hpp -+++ b/include/lance/lance.hpp -@@ -49,6 +49,12 @@ inline void check_error() { - } - } - -+/// Release and free a library-allocated ArrowArrayStream returned by -+/// Scanner::scan_async. NULL-safe; do not use for caller-allocated streams. -+inline void scanner_async_stream_free(ArrowArrayStream* stream) noexcept { -+ lance_scanner_async_stream_free(stream); -+} -+ - // ─── RAII Handle Template ──────────────────────────────────────────────────── - - template -@@ -89,6 +95,7 @@ class Scanner; - class IndexModel; - class IndexSegmentBuilder; - class IndexSegmentMetadata; -+class FtsQueryContext; - - // ─── Version history ───────────────────────────────────────────────────────── - -@@ -116,6 +123,11 @@ enum class WriteMode : int32_t { - Overwrite = LANCE_WRITE_OVERWRITE, - }; - -+enum class FtsCoverageMode : int32_t { -+ Strict = LANCE_FTS_COVERAGE_STRICT, -+ IndexOnly = LANCE_FTS_COVERAGE_INDEX_ONLY, -+}; -+ - /// Tunable parameters for Dataset::write. Numeric fields default-out via 0; - /// `data_storage_version` defaults out via `std::nullopt`. - /// -@@ -157,6 +169,24 @@ struct SqlColumn { - std::string expression; - }; - -+// ─── Process-local FTS query context ──────────────────────────────────────── -+ -+/// Immutable, query-specific global BM25 scorer plus pinned FTS segment list. -+/// This handle is process-local and intentionally has no serialization API. -+class FtsQueryContext { -+ Handle handle_; -+ -+public: -+ explicit FtsQueryContext(LanceFtsQueryContext* context) : handle_(context) {} -+ -+ FtsQueryContext(FtsQueryContext&&) noexcept = default; -+ FtsQueryContext& operator=(FtsQueryContext&&) noexcept = default; -+ FtsQueryContext(const FtsQueryContext&) = delete; -+ FtsQueryContext& operator=(const FtsQueryContext&) = delete; -+ -+ const LanceFtsQueryContext* c_handle() const { return handle_.get(); } -+}; -+ - // ─── Dataset ───────────────────────────────────────────────────────────────── - - class Dataset { -@@ -329,7 +359,9 @@ public: - - /// Version of this dataset snapshot. - uint64_t version() const { -- return lance_dataset_version(handle_.get()); -+ uint64_t v = lance_dataset_version(handle_.get()); -+ if (lance_last_error_code() != LANCE_OK) check_error(); -+ return v; - } - - /// Latest version ID (queries object store). -@@ -347,11 +379,13 @@ public: - Handle snap(raw); - - uint64_t n = lance_versions_count(snap.get()); -+ if (lance_last_error_code() != LANCE_OK) check_error(); - std::vector out; - out.reserve(static_cast(n)); - for (uint64_t i = 0; i < n; i++) { - VersionInfo info; - info.id = lance_versions_id_at(snap.get(), static_cast(i)); -+ if (lance_last_error_code() != LANCE_OK) check_error(); - info.timestamp_ms = - lance_versions_timestamp_ms_at(snap.get(), static_cast(i)); - if (lance_last_error_code() != LANCE_OK) check_error(); -@@ -369,11 +403,13 @@ public: - Handle snap(raw); - - uint64_t n = lance_data_statistics_count(snap.get()); -+ if (lance_last_error_code() != LANCE_OK) check_error(); - std::vector out; - out.reserve(static_cast(n)); - for (uint64_t i = 0; i < n; i++) { - FieldStatistics fs; - fs.id = lance_data_statistics_field_id_at(snap.get(), static_cast(i)); -+ if (lance_last_error_code() != LANCE_OK) check_error(); - fs.bytes_on_disk = - lance_data_statistics_bytes_on_disk_at(snap.get(), static_cast(i)); - if (lance_last_error_code() != LANCE_OK) check_error(); -@@ -631,7 +667,9 @@ public: - } - } - -- /// Take rows by indices. Results exported as ArrowArrayStream. -+ /// Take rows by indices. `out` is caller-owned and its non-null `release` -+ /// must be called exactly once. Deferred iteration/cleanup panics are -+ /// contained by the exported stream guard. - void take(const uint64_t* indices, size_t num_indices, - const std::vector& columns, - ArrowArrayStream* out) const { -@@ -645,7 +683,7 @@ public: - } - } - -- /// Take all columns. -+ /// Take all columns with the same stream ownership as the overload above. - void take(const uint64_t* indices, size_t num_indices, - ArrowArrayStream* out) const { - if (lance_dataset_take(handle_.get(), indices, num_indices, nullptr, out) != 0) { -@@ -653,7 +691,9 @@ public: - } - } - -- /// Take rows by dataset row IDs. Results exported as ArrowArrayStream. -+ /// Take rows by dataset row IDs. `out` is caller-owned and its non-null -+ /// `release` must be called exactly once. Deferred iteration/cleanup panics -+ /// are contained by the exported stream guard. - void take_rows(const uint64_t* row_ids, size_t num_row_ids, - const std::vector& columns, - ArrowArrayStream* out) const { -@@ -668,7 +708,8 @@ public: - } - } - -- /// Take all columns by dataset row IDs. -+ /// Take all columns by dataset row IDs with the same stream ownership as -+ /// the overload above. - void take_rows(const uint64_t* row_ids, size_t num_row_ids, - ArrowArrayStream* out) const { - if (lance_dataset_take_rows( -@@ -680,6 +721,23 @@ public: - /// Create a Scanner builder for this dataset. - Scanner scan() const; - -+ /// Prepare a query-specific global BM25 scorer over the committed FTS -+ /// segments of this pinned snapshot. IndexOnly permits unindexed fragments; -+ /// Strict rejects them. Prepared contexts currently require -+ /// `max_fuzzy_distance == 0`. The context can only be attached to scanners -+ /// created from this exact process-local dataset snapshot. -+ FtsQueryContext prepare_fts_query( -+ const std::string& column, -+ const std::string& query, -+ uint32_t max_fuzzy_distance = 0, -+ FtsCoverageMode coverage_mode = FtsCoverageMode::Strict) const { -+ auto* context = lance_dataset_prepare_fts_query( -+ handle_.get(), column.c_str(), query.c_str(), max_fuzzy_distance, -+ static_cast(coverage_mode)); -+ if (!context) check_error(); -+ return FtsQueryContext(context); -+ } -+ - /// Number of fragments in the dataset. - uint64_t fragment_count() const { - uint64_t n = lance_dataset_fragment_count(handle_.get()); -@@ -783,7 +841,7 @@ public: - /// Throws lance::Error with code NotFound if the index does not exist. - uint64_t index_segment_count(const std::string& index_name) const { - uint64_t n = lance_dataset_index_segment_count(handle_.get(), index_name.c_str()); -- if (n == 0 && lance_last_error_code() != LANCE_OK) check_error(); -+ if (lance_last_error_code() != LANCE_OK) check_error(); - return n; - } - -@@ -1127,7 +1185,14 @@ public: - return substrait_filter(bytes.data(), bytes.size()); - } - -- /// Register a callback for scan statistics after successful full exhaustion. -+ /// Add an SQL filter that is combined with the selected primary filter using AND. -+ Scanner& additional_sql_filter(const std::string& filter) { -+ if (lance_scanner_additional_sql_filter(handle_.get(), filter.c_str()) != 0) -+ check_error(); -+ return *this; -+ } -+ -+ /// Register a non-null callback for scan statistics after successful full exhaustion. - /// The registration applies to every stream derived from this scanner, including - /// concurrent streams and streams created after an earlier callback returns. The - /// callback is not guaranteed on error, cancellation, or early release. It may -@@ -1166,12 +1231,20 @@ public: - } - - /// Materialize an independent ArrowArrayStream (blocking). The scanner remains valid. -+ /// `out` is caller-owned; call its non-null `release` callback exactly once. - void to_arrow_stream(ArrowArrayStream* out) { - if (lance_scanner_to_arrow_stream(handle_.get(), out) != 0) - check_error(); - } - -- /// Start an async scan. Callback fires when ArrowArrayStream is ready. -+ /// Start an async scan with a non-null callback. On success, the callback's -+ /// ArrowArrayStream result is library-allocated and must be passed exactly -+ /// once to `lance::scanner_async_stream_free`, which also invokes `release` -+ /// when necessary. The callback normally runs on the dispatcher thread, -+ /// but a rare infrastructure fallback may invoke it on the calling or -+ /// producing thread, possibly before this method returns, so it must be -+ /// thread-safe. Exactly one completion is delivered; callback and non-null -+ /// context storage must remain valid until it returns. - void scan_async(LanceCallback callback, void* ctx) const { - lance_scanner_scan_async(handle_.get(), callback, ctx); - } -@@ -1235,6 +1308,29 @@ public: - return *this; - } - -+ /// Attach a process-local prepared FTS query context. The scanner retains -+ /// shared ownership, so the context object may be destroyed after success. -+ Scanner& fts_query_context(const FtsQueryContext& context) { -+ if (lance_scanner_set_fts_query_context(handle_.get(), context.c_handle()) != 0) -+ check_error(); -+ return *this; -+ } -+ -+ /// Restrict a context-backed FTS query to a segment UUID subset. -+ Scanner& fts_index_segments(const uint8_t* segment_uuids, size_t segment_count) { -+ if (lance_scanner_set_fts_index_segments( -+ handle_.get(), segment_uuids, segment_count) != 0) -+ check_error(); -+ return *this; -+ } -+ -+ Scanner& fts_index_segments( -+ const std::vector>& segment_uuids) { -+ return fts_index_segments( -+ reinterpret_cast(segment_uuids.data()), -+ segment_uuids.size()); -+ } -+ - /// Access the underlying C handle. - LanceScanner* c_handle() { return handle_.get(); } - }; -diff --git a/src/add_columns.rs b/src/add_columns.rs -index 5300c26..a6f30b8 100644 ---- a/src/add_columns.rs -+++ b/src/add_columns.rs -@@ -176,7 +176,7 @@ unsafe fn add_columns_nulls_inner( - let ffi_schema = unsafe { &*schema }; - // Reject an already-released or never-initialised schema before handing it - // to arrow-rs, which would otherwise `assert!` on the NULL `format` field -- // and abort the host process under our `panic = "abort"` profile. Both -+ // and turn predictable invalid input into LANCE_ERR_PANIC. Both - // checks are intentional — `release == NULL` is the canonical Arrow C Data - // Interface "released" sentinel, while `format == NULL` catches a - // zero-initialised or half-built struct that would slip past the release -@@ -187,8 +187,8 @@ unsafe fn add_columns_nulls_inner( - )); - } - // arrow-rs's `FFI_ArrowSchema::format()` does `to_str().expect(..)` on the -- // format pointer; a non-NULL but non-UTF-8 top-level format would abort the -- // process under `panic = "abort"`. Validate it here so a malformed format -+ // format pointer; a non-NULL but non-UTF-8 top-level format would panic in -+ // the guarded FFI boundary. Validate it here so a malformed format - // surfaces as INVALID_ARGUMENT instead. (Child fields are still the caller's - // responsibility — see the doc comment — as walking them would duplicate - // arrow-rs's recursive descent.) -@@ -273,8 +273,8 @@ unsafe fn add_columns_stream_inner( - // Reject a stream missing a mandatory C Data Interface callback *before* - // handing it to arrow-rs. `ArrowArrayStreamReader` only guards against a - // NULL `release`; a NULL `get_schema` or `get_next` would otherwise reach an -- // `unwrap()` deep inside arrow-rs and abort the host process under our -- // `panic = "abort"` profile. We do not require `get_last_error` (the spec -+ // `unwrap()` deep inside arrow-rs and turn predictable invalid input into -+ // LANCE_ERR_PANIC. We do not require `get_last_error` (the spec - // marks it optional): requiring it would not close the abort anyway, since a - // present callback that *returns* NULL at error time hits the same - // `last_error.unwrap()` on arrow-rs's `get_next` error path — a residual -diff --git a/src/alter_columns.rs b/src/alter_columns.rs -index 5f85fae..e2da121 100644 ---- a/src/alter_columns.rs -+++ b/src/alter_columns.rs -@@ -208,8 +208,8 @@ unsafe fn parse_alteration( - let ffi_schema = unsafe { &*entry.data_type }; - // Reject an already-released or never-initialised schema before - // handing it to arrow-rs, which would otherwise `assert!` on the -- // NULL `format` field and abort the host process under our -- // `panic = "abort"` profile. Both checks are intentional: -+ // NULL `format` field and turn predictable invalid input into -+ // LANCE_ERR_PANIC. Both checks are intentional: - // - `release == NULL`: the canonical Arrow CADI "released" sentinel. - // - `format == NULL`: catches a zero-initialised or otherwise - // half-built struct that would slip past the release check. -diff --git a/src/async_dispatcher.rs b/src/async_dispatcher.rs -index 91df74d..0112ed5 100644 ---- a/src/async_dispatcher.rs -+++ b/src/async_dispatcher.rs -@@ -42,7 +42,7 @@ struct Dispatcher { - } - - impl Dispatcher { -- fn new() -> Self { -+ fn new() -> std::io::Result { - let (tx, rx) = mpsc::channel::(); - - std::thread::Builder::new() -@@ -50,48 +50,62 @@ impl Dispatcher { - .spawn(move || { - log::debug!("Lance C dispatcher thread started"); - while let Ok(msg) = rx.recv() { -- // Install the carried error on THIS thread's TLS so the -- // callback's `lance_last_error_*` calls observe it. TLS -- // persists across callbacks on this thread, so a success -- // must explicitly clear: a stale error from an earlier -- // failed callback must never leak into a later one. -- match &msg.error { -- Some((code, message)) => set_last_error(*code, message), -- None => clear_last_error(), -- } -- // Invoke the C callback under catch_unwind, best-effort -- // only (issue #61). The declared callback ABI is -- // `extern "C"` and therefore NON-unwinding — `lance.h` -- // requires callbacks not to panic, and a panic in such a -- // callback aborts at its own boundary before this catch -- // could ever run. The catch exists solely for Rust hosts -- // that pass an `extern "C-unwind"` callback: for them it -- // keeps the dispatcher thread (and with it every later -- // async completion) alive. It is not part of the panic -- // contract and must never be relied on as one. -- let outcome = catch_unwind(AssertUnwindSafe(|| unsafe { -- (msg.callback)(msg.callback_ctx, msg.status, msg.result); -- })); -- if let Err(payload) = outcome { -- log::error!( -- "lance-c dispatcher: unwinding (C-unwind) host callback panicked; contained best-effort: {}", -- panic_payload_message(&*payload) -- ); -- } -+ deliver_message(msg); - } - log::debug!("Lance C dispatcher thread shutting down"); -- }) -- .expect("Failed to spawn lance-c dispatcher thread"); -+ })?; - -- Self { tx } -+ Ok(Self { tx }) - } - -- fn send(&self, msg: DispatcherMessage) { -- let _ = self.tx.send(msg); -+ fn send(&self, msg: DispatcherMessage) -> Result<(), DispatcherMessage> { -+ self.tx.send(msg).map_err(|err| err.0) - } - } - --static DISPATCHER: LazyLock = LazyLock::new(Dispatcher::new); -+/// Install one completion's TLS state and invoke its callback on the current -+/// thread. Normally that thread is the dispatcher; this is also the fallback -+/// when dispatcher creation or channel delivery fails, preserving the -+/// exactly-once completion contract instead of silently dropping the message. -+fn deliver_message(msg: DispatcherMessage) { -+ match &msg.error { -+ Some((code, message)) => set_last_error(*code, message), -+ None => clear_last_error(), -+ } -+ -+ // Best-effort only (issue #61). A real `extern "C"` callback cannot -+ // unwind; a panic aborts at its own boundary before this catch runs. The -+ // catch only helps Rust hosts that deliberately supply a C-unwind shim. -+ let outcome = catch_unwind(AssertUnwindSafe(|| unsafe { -+ (msg.callback)(msg.callback_ctx, msg.status, msg.result); -+ })); -+ if let Err(payload) = outcome { -+ log::error!( -+ "lance-c dispatcher: unwinding host callback panicked; contained best-effort: {}", -+ panic_payload_message(&*payload) -+ ); -+ } -+} -+ -+fn dispatch_message(dispatcher: Option<&Dispatcher>, msg: DispatcherMessage) { -+ let undelivered = match dispatcher { -+ Some(dispatcher) => match dispatcher.send(msg) { -+ Ok(()) => return, -+ Err(msg) => msg, -+ }, -+ None => msg, -+ }; -+ log::error!("lance-c dispatcher unavailable; invoking async completion on the current thread"); -+ deliver_message(undelivered); -+} -+ -+static DISPATCHER: LazyLock> = LazyLock::new(|| match Dispatcher::new() { -+ Ok(dispatcher) => Some(dispatcher), -+ Err(err) => { -+ log::error!("failed to start lance-c dispatcher thread: {err}"); -+ None -+ } -+}); - - /// Send a completion message to the dispatcher thread. Before invoking the - /// callback, the dispatcher installs `error` on its own thread-local error -@@ -105,13 +119,16 @@ pub(crate) fn dispatch_callback( - result: *mut c_void, - error: Option<(LanceErrorCode, String)>, - ) { -- DISPATCHER.send(DispatcherMessage { -- callback, -- callback_ctx, -- status, -- result, -- error, -- }); -+ dispatch_message( -+ DISPATCHER.as_ref(), -+ DispatcherMessage { -+ callback, -+ callback_ctx, -+ status, -+ result, -+ error, -+ }, -+ ); - } - - #[cfg(test)] -@@ -234,4 +251,53 @@ mod tests { - - unsafe { reclaim(ctx) }; - } -+ -+ #[test] -+ fn unavailable_dispatcher_falls_back_without_dropping_completion() { -+ let (rx, ctx) = probe(); -+ dispatch_message( -+ None, -+ DispatcherMessage { -+ callback: observe, -+ callback_ctx: ctx, -+ status: -1, -+ result: ptr::null_mut(), -+ error: Some(( -+ LanceErrorCode::Internal, -+ "dispatcher unavailable".to_string(), -+ )), -+ }, -+ ); -+ -+ let obs = recv(&rx); -+ assert_eq!(obs.status, -1); -+ assert_eq!(obs.code, LanceErrorCode::Internal); -+ assert_eq!(obs.message.as_deref(), Some("dispatcher unavailable")); -+ unsafe { reclaim(ctx) }; -+ } -+ -+ #[test] -+ fn closed_dispatch_channel_falls_back_without_dropping_completion() { -+ let (tx, dead_rx) = mpsc::channel(); -+ drop(dead_rx); -+ let dispatcher = Dispatcher { tx }; -+ let (rx, ctx) = probe(); -+ -+ dispatch_message( -+ Some(&dispatcher), -+ DispatcherMessage { -+ callback: observe, -+ callback_ctx: ctx, -+ status: 0, -+ result: ptr::dangling_mut::(), -+ error: None, -+ }, -+ ); -+ -+ let obs = recv(&rx); -+ assert_eq!(obs.status, 0); -+ assert!(!obs.result_was_null); -+ assert_eq!(obs.code, LanceErrorCode::Ok); -+ unsafe { reclaim(ctx) }; -+ } - } -diff --git a/src/dataset.rs b/src/dataset.rs -index 9fe63e7..1397b74 100644 ---- a/src/dataset.rs -+++ b/src/dataset.rs -@@ -17,6 +17,7 @@ use lance_core::Result; - use crate::error::{ffi_try, swallow_unwind}; - use crate::helpers; - use crate::runtime::block_on; -+use crate::stream_guard::guarded_ffi_stream_from_reader; - - /// Opaque handle representing an opened Lance dataset. - pub struct LanceDataset { -@@ -151,7 +152,8 @@ unsafe fn open_dataset_inner( - } - - /// Close and free a dataset handle. --/// Safe to call with NULL. Safe to call multiple times (subsequent calls are no-ops). -+/// Safe to call with NULL. A non-NULL handle must be closed exactly once and -+/// must not be used again afterwards. - /// - /// Best-effort (issue #61): a panic raised while dropping the handle is - /// caught and logged rather than unwinding into the caller, and the -@@ -268,6 +270,10 @@ unsafe fn dataset_schema_inner( - /// - `columns`: NULL-terminated column name array, or NULL for all columns - /// - `out`: pointer to a stack-allocated `ArrowArrayStream` - /// -+/// The already-materialized batch is exported through a guarded reader: -+/// schema conversion is validated before callbacks are exposed, and later -+/// `get_next` / `release` panics are contained at the Arrow C boundary. -+/// - /// Returns 0 on success, -1 on error. - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_dataset_take( -@@ -307,7 +313,7 @@ unsafe fn dataset_take_inner( - // Wrap the single RecordBatch as a RecordBatchReader, then export as FFI stream. - let schema = batch.schema(); - let reader = arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema); -- let ffi_stream = FFI_ArrowArrayStream::new(Box::new(reader)); -+ let ffi_stream = guarded_ffi_stream_from_reader(reader)?; - unsafe { - std::ptr::write_unaligned(out, ffi_stream); - } -@@ -326,6 +332,10 @@ unsafe fn dataset_take_inner( - /// to the same dataset snapshot used for this read. Missing or deleted row IDs - /// may be omitted from the result by the upstream Lance implementation. - /// -+/// The already-materialized batch is exported through the same guarded reader -+/// as [`lance_dataset_take`], including schema preflight and deferred callback -+/// panic containment. -+/// - /// Returns 0 on success, -1 on error. - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_dataset_take_rows( -@@ -376,7 +386,7 @@ unsafe fn dataset_take_rows_inner( - // Match lance_dataset_take: export the single RecordBatch as an Arrow stream. - let schema = batch.schema(); - let reader = arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema); -- let ffi_stream = FFI_ArrowArrayStream::new(Box::new(reader)); -+ let ffi_stream = guarded_ffi_stream_from_reader(reader)?; - unsafe { - std::ptr::write_unaligned(out, ffi_stream); - } -@@ -492,10 +502,19 @@ mod tests { - #[test] - fn with_mut_panic_rolls_back_and_handle_stays_usable() { - let (_tmp, handle) = create_test_handle(); -+ let (_replacement_tmp, replacement_handle) = create_test_handle(); -+ let replacement = Dataset::clone(&*replacement_handle.snapshot()); - let uri_before = handle.snapshot().uri().to_string(); -+ assert_ne!(replacement.uri(), uri_before); - - let result = catch_unwind(AssertUnwindSafe(|| { -- handle.with_mut(|_ds| panic!("simulated bug in mutation")) -+ handle.with_mut(|ds| { -+ // Make a visible in-memory mutation before panicking. This -+ // distinguishes clone-execute-swap from mutating the handle's -+ // stored Dataset in place and merely skipping the final swap. -+ *ds = replacement; -+ panic!("simulated bug in mutation") -+ }) - })); - let payload = result.expect_err("panic must escape with_mut unchanged"); - let msg = crate::error::panic_payload_message(&*payload); -diff --git a/src/error.rs b/src/error.rs -index f8158d9..0fe31c4 100644 ---- a/src/error.rs -+++ b/src/error.rs -@@ -88,6 +88,64 @@ pub fn set_lance_error(err: &lance_core::Error) { - set_last_error(error_code_from_lance(err), err.to_string()); - } - -+/// Why an [`ffi_guard_with`] invocation failed. -+pub(crate) enum FfiFailure { -+ /// The guarded body returned a regular `lance_core::Error`. -+ Lance, -+ /// Something panicked while executing the body or mapping its result. -+ Panic, -+} -+ -+/// Finish a caught FFI panic without leaving panic reporting unguarded. -+/// -+/// A failure while recording the panic or constructing the caller's error -+/// value is itself caught. There is no type-safe value we can manufacture if -+/// that recovery also panics, so the second payload is resumed; this is the -+/// documented double-panic limit of the FFI firewall. -+fn recover_from_ffi_panic( -+ payload: Box, -+ recover: impl FnOnce() -> T, -+) -> T { -+ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { -+ set_last_error( -+ LanceErrorCode::Panic, -+ format!("panic in FFI call: {}", panic_payload_message(&*payload)), -+ ); -+ recover() -+ })) { -+ Ok(value) => value, -+ Err(payload) => std::panic::resume_unwind(payload), -+ } -+} -+ -+/// Run a complete fallible FFI operation under the panic firewall and map any -+/// failure to the ABI-specific return value. -+/// -+/// The guard deliberately includes result mapping, not just `body()`: a -+/// wrapped external error may itself panic from `Display` while -+/// [`set_lance_error`] formats it. Keeping formatting, TLS mutation, and the -+/// error sentinel inside the unwind boundary prevents those secondary panics -+/// from escaping an `extern "C"` entry point. If the sentinel itself panics, -+/// the recovery path records `LanceErrorCode::Panic` and asks for it once more. -+pub(crate) fn ffi_guard_with( -+ body: impl FnOnce() -> lance_core::Result, -+ mut on_failure: impl FnMut(FfiFailure) -> T, -+) -> T { -+ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match body() { -+ Ok(value) => { -+ clear_last_error(); -+ value -+ } -+ Err(err) => { -+ set_lance_error(&err); -+ on_failure(FfiFailure::Lance) -+ } -+ })) { -+ Ok(value) => value, -+ Err(payload) => recover_from_ffi_panic(payload, || on_failure(FfiFailure::Panic)), -+ } -+} -+ - /// Extract a human-readable message from a `catch_unwind` panic payload. - /// - /// `panic!` only ever produces `&str` or `String` payloads; anything else -@@ -182,89 +240,16 @@ pub unsafe extern "C" fn lance_free_string(s: *const c_char) { - /// captured by the `$errval:expr` catch-all. - macro_rules! ffi_try { - ($body:expr, null) => { -- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { -- Ok(Ok(val)) => { -- $crate::error::clear_last_error(); -- val -- } -- Ok(Err(err)) => { -- $crate::error::set_lance_error(&err); -- std::ptr::null_mut() -- } -- Err(payload) => { -- $crate::error::set_last_error( -- $crate::error::LanceErrorCode::Panic, -- format!( -- "panic in FFI call: {}", -- $crate::error::panic_payload_message(&*payload) -- ), -- ); -- std::ptr::null_mut() -- } -- } -+ $crate::error::ffi_guard_with(|| $body, |_| std::ptr::null_mut()) - }; - ($body:expr, neg) => { -- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { -- Ok(Ok(val)) => { -- $crate::error::clear_last_error(); -- val -- } -- Ok(Err(err)) => { -- $crate::error::set_lance_error(&err); -- -1 -- } -- Err(payload) => { -- $crate::error::set_last_error( -- $crate::error::LanceErrorCode::Panic, -- format!( -- "panic in FFI call: {}", -- $crate::error::panic_payload_message(&*payload) -- ), -- ); -- -1 -- } -- } -+ $crate::error::ffi_guard_with(|| $body, |_| -1) - }; - ($body:expr, void) => { -- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { -- Ok(Ok(_)) => { -- $crate::error::clear_last_error(); -- } -- Ok(Err(err)) => { -- $crate::error::set_lance_error(&err); -- } -- Err(payload) => { -- $crate::error::set_last_error( -- $crate::error::LanceErrorCode::Panic, -- format!( -- "panic in FFI call: {}", -- $crate::error::panic_payload_message(&*payload) -- ), -- ); -- } -- } -+ $crate::error::ffi_guard_with(|| $body, |_| ()) - }; - ($body:expr, $errval:expr) => { -- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { -- Ok(Ok(val)) => { -- $crate::error::clear_last_error(); -- val -- } -- Ok(Err(err)) => { -- $crate::error::set_lance_error(&err); -- $errval -- } -- Err(payload) => { -- $crate::error::set_last_error( -- $crate::error::LanceErrorCode::Panic, -- format!( -- "panic in FFI call: {}", -- $crate::error::panic_payload_message(&*payload) -- ), -- ); -- $errval -- } -- } -+ $crate::error::ffi_guard_with(|| $body, |_| $errval) - }; - } - -@@ -275,6 +260,17 @@ mod tests { - use super::*; - use std::ffi::CStr; - -+ #[derive(Debug)] -+ struct PanickingDisplay; -+ -+ impl std::fmt::Display for PanickingDisplay { -+ fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -+ panic!("simulated panic while formatting an FFI error") -+ } -+ } -+ -+ impl std::error::Error for PanickingDisplay {} -+ - /// Yields a `lance_core::Result` by panicking — the panic is what the - /// `ffi_try!` shapes under test must catch. (The panic hook prints to - /// stderr during these tests; that is expected noise.) -@@ -402,6 +398,48 @@ mod tests { - assert!(msg.contains("bad arg"), "got: {msg}"); - } - -+ #[test] -+ fn ffi_try_catches_panic_while_formatting_lance_error() { -+ let v: u64 = ffi_try!( -+ Err(lance_core::Error::invalid_input_source(Box::new( -+ PanickingDisplay, -+ ))), -+ 0 -+ ); -+ assert_eq!(v, 0); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::Panic); -+ let msg = take_last_error_message().expect("panic must set a message"); -+ assert!( -+ msg.contains("simulated panic while formatting an FFI error"), -+ "got: {msg}" -+ ); -+ } -+ -+ #[test] -+ fn ffi_try_catches_panic_while_building_error_sentinel() { -+ let attempts = std::cell::Cell::new(0); -+ let v: i64 = ffi_try!( -+ Err(lance_core::Error::invalid_input_source("bad arg".into())), -+ { -+ let attempt = attempts.get(); -+ attempts.set(attempt + 1); -+ if attempt == 0 { -+ panic!("simulated panic while building an FFI error sentinel"); -+ } -+ 7 -+ } -+ ); -+ -+ assert_eq!(v, 7); -+ assert_eq!(attempts.get(), 2); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::Panic); -+ let msg = take_last_error_message().expect("panic must set a message"); -+ assert!( -+ msg.contains("simulated panic while building an FFI error sentinel"), -+ "got: {msg}" -+ ); -+ } -+ - #[test] - fn ffi_try_errval_maps_panic_to_errval_and_panic_code() { - // A non-zero sentinel proves the arm returns `$errval` verbatim. -diff --git a/src/fts_query.rs b/src/fts_query.rs -new file mode 100644 -index 0000000..e85ed99 ---- /dev/null -+++ b/src/fts_query.rs -@@ -0,0 +1,326 @@ -+// SPDX-License-Identifier: Apache-2.0 -+// SPDX-FileCopyrightText: Copyright The Lance Authors -+ -+//! Process-local, immutable FTS query context shared by segment-scoped scans. -+ -+use std::collections::HashSet; -+use std::ffi::c_char; -+use std::ptr; -+use std::sync::Arc; -+ -+use futures::future::try_join_all; -+use lance::index::{DatasetIndexExt, DatasetIndexInternalExt}; -+use lance_core::{Error, Result}; -+use lance_index::IndexCriteria; -+use lance_index::metrics::NoOpMetricsCollector; -+use lance_index::scalar::FullTextSearchQuery; -+use lance_index::scalar::inverted::query::{FtsQuery, collect_query_tokens}; -+use lance_index::scalar::inverted::{InvertedIndex, MemBM25Scorer, build_global_bm25_scorer}; -+use lance_table::format::IndexMetadata; -+use uuid::Uuid; -+ -+use crate::dataset::LanceDataset; -+use crate::error::{ffi_try, swallow_unwind}; -+use crate::helpers; -+use crate::runtime::block_on; -+ -+/// Required relationship between the pinned dataset snapshot and its FTS index. -+#[repr(i32)] -+#[derive(Clone, Copy, Debug, PartialEq, Eq)] -+pub enum LanceFtsCoverageMode { -+ /// Every current fragment must be covered by a committed FTS segment. -+ Strict = 0, -+ /// Search and score only documents covered by committed FTS segments. -+ IndexOnly = 1, -+} -+ -+impl TryFrom for LanceFtsCoverageMode { -+ type Error = Error; -+ -+ fn try_from(value: i32) -> Result { -+ match value { -+ 0 => Ok(Self::Strict), -+ 1 => Ok(Self::IndexOnly), -+ _ => Err(Error::invalid_input(format!( -+ "invalid coverage_mode {value}; expected 0 (STRICT) or 1 (INDEX_ONLY)" -+ ))), -+ } -+ } -+} -+ -+/// Rust-owned immutable state behind [`LanceFtsQueryContext`]. -+pub(crate) struct FtsQueryContextInner { -+ pub(crate) dataset: Arc, -+ pub(crate) query: FullTextSearchQuery, -+ pub(crate) segments: Vec, -+ pub(crate) scorer: Arc, -+ pub(crate) has_unindexed_fragments: bool, -+} -+ -+impl FtsQueryContextInner { -+ pub(crate) fn validate_dataset_identity(&self, dataset: &Arc) -> Result<()> { -+ if !Arc::ptr_eq(&self.dataset, dataset) { -+ return Err(invalid_input(format!( -+ "FTS query context and scanner must originate from the same process-local dataset snapshot; context has uri '{}' version {}, scanner has uri '{}' version {}", -+ self.dataset.uri(), -+ self.dataset.version_id(), -+ dataset.uri(), -+ dataset.version_id() -+ ))); -+ } -+ Ok(()) -+ } -+} -+ -+/// Opaque process-local FTS query context. -+/// -+/// The handle owns an `Arc`, and scanners clone that `Arc` when the context is -+/// attached. It is therefore safe to close the public handle after all scanner -+/// attachments have completed. -+pub struct LanceFtsQueryContext { -+ pub(crate) inner: Arc, -+} -+ -+fn invalid_input(message: impl Into) -> Error { -+ Error::invalid_input(message.into()) -+} -+ -+async fn prepare_fts_query_context( -+ dataset: Arc, -+ column: String, -+ query_text: String, -+ coverage_mode: LanceFtsCoverageMode, -+) -> Result { -+ let logical_index = dataset -+ .load_scalar_index(IndexCriteria::default().for_column(&column).supports_fts()) -+ .await? -+ .ok_or_else(|| { -+ invalid_input(format!( -+ "no committed FTS index exists for column '{column}' in dataset version {}", -+ dataset.version_id() -+ )) -+ })?; -+ let segments = dataset.load_indices_by_name(&logical_index.name).await?; -+ if segments.is_empty() { -+ return Err(invalid_input(format!( -+ "FTS index for column '{column}' has no committed segments in dataset version {}", -+ dataset.version_id() -+ ))); -+ } -+ -+ let expected_fields = &segments[0].fields; -+ if let Some(segment) = segments -+ .iter() -+ .find(|segment| &segment.fields != expected_fields) -+ { -+ return Err(invalid_input(format!( -+ "FTS index '{}' has inconsistent fields across segments; segment {} has fields {:?}, expected {:?}", -+ logical_index.name, segment.uuid, segment.fields, expected_fields -+ ))); -+ } -+ -+ let current_fragment_ids: HashSet = dataset -+ .get_fragments() -+ .into_iter() -+ .map(|fragment| { -+ u32::try_from(fragment.id()).map_err(|_| { -+ invalid_input(format!( -+ "fragment id {} exceeds the u32 index metadata range", -+ fragment.id() -+ )) -+ }) -+ }) -+ .collect::>()?; -+ -+ let mut indexed_fragment_ids = HashSet::new(); -+ for segment in &segments { -+ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { -+ invalid_input(format!( -+ "FTS segment {} for column '{column}' has unknown fragment coverage", -+ segment.uuid -+ )) -+ })?; -+ indexed_fragment_ids.extend( -+ fragment_bitmap -+ .iter() -+ .filter(|fragment_id| current_fragment_ids.contains(fragment_id)), -+ ); -+ } -+ let mut unindexed_fragment_ids: Vec = current_fragment_ids -+ .difference(&indexed_fragment_ids) -+ .copied() -+ .collect(); -+ unindexed_fragment_ids.sort_unstable(); -+ -+ if coverage_mode == LanceFtsCoverageMode::Strict && !unindexed_fragment_ids.is_empty() { -+ return Err(invalid_input(format!( -+ "coverage_mode=STRICT requires every fragment in dataset version {} to be indexed; column '{column}' has {} unindexed fragments: {:?}", -+ dataset.version_id(), -+ unindexed_fragment_ids.len(), -+ unindexed_fragment_ids -+ ))); -+ } -+ -+ let indices: Vec> = try_join_all(segments.iter().map(|segment| { -+ let dataset = Arc::clone(&dataset); -+ let column = column.clone(); -+ async move { -+ let index = dataset -+ .open_scalar_index(&column, &segment.uuid, &NoOpMetricsCollector) -+ .await?; -+ let inverted = index -+ .as_any() -+ .downcast_ref::() -+ .ok_or_else(|| { -+ invalid_input(format!( -+ "index segment {} for column '{column}' is not an inverted index", -+ segment.uuid -+ )) -+ })?; -+ Ok::<_, Error>(Arc::new(inverted.clone())) -+ } -+ })) -+ .await?; -+ -+ let expected_params = indices[0].params(); -+ if let Some((position, _)) = indices -+ .iter() -+ .enumerate() -+ .find(|(_, index)| index.params() != expected_params) -+ { -+ return Err(invalid_input(format!( -+ "FTS index '{}' has inconsistent inverted index parameters; segment {} differs from segment {}", -+ logical_index.name, segments[position].uuid, segments[0].uuid -+ ))); -+ } -+ -+ let query = FullTextSearchQuery::new(query_text).with_column(column.clone())?; -+ let match_query = match &query.query { -+ FtsQuery::Match(query) => query, -+ _ => { -+ return Err(Error::internal( -+ "prepared FTS query unexpectedly produced a non-Match query".to_string(), -+ )); -+ } -+ }; -+ let mut tokenizer = indices[0].tokenizer(); -+ let query_tokens = collect_query_tokens(&match_query.terms, &mut tokenizer); -+ let params = query -+ .params() -+ .with_fuzziness(match_query.fuzziness) -+ .with_max_expansions(match_query.max_expansions) -+ .with_prefix_length(match_query.prefix_length); -+ let scorer = Arc::new(build_global_bm25_scorer(&indices, &query_tokens, ¶ms).await?); -+ -+ Ok(FtsQueryContextInner { -+ dataset, -+ query, -+ segments, -+ scorer, -+ has_unindexed_fragments: !unindexed_fragment_ids.is_empty(), -+ }) -+} -+ -+/// Prepare a process-local global BM25 scorer and the committed segment list -+/// for one single-column Match query against the dataset's pinned snapshot. -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_dataset_prepare_fts_query( -+ dataset: *const LanceDataset, -+ column: *const c_char, -+ query: *const c_char, -+ max_fuzzy_distance: u32, -+ coverage_mode: i32, -+) -> *mut LanceFtsQueryContext { -+ ffi_try!( -+ unsafe { -+ prepare_fts_query_inner(dataset, column, query, max_fuzzy_distance, coverage_mode) -+ }, -+ null -+ ) -+} -+ -+unsafe fn prepare_fts_query_inner( -+ dataset: *const LanceDataset, -+ column: *const c_char, -+ query: *const c_char, -+ max_fuzzy_distance: u32, -+ coverage_mode: i32, -+) -> Result<*mut LanceFtsQueryContext> { -+ if dataset.is_null() || column.is_null() || query.is_null() { -+ return Err(invalid_input("dataset, column, and query must not be NULL")); -+ } -+ let column = unsafe { helpers::parse_c_string(column)? } -+ .filter(|value| !value.is_empty()) -+ .ok_or_else(|| invalid_input("column must not be empty"))? -+ .to_string(); -+ let query = unsafe { helpers::parse_c_string(query)? } -+ .filter(|value| !value.is_empty()) -+ .ok_or_else(|| invalid_input("query must not be empty"))? -+ .to_string(); -+ let coverage_mode = LanceFtsCoverageMode::try_from(coverage_mode)?; -+ if max_fuzzy_distance != 0 { -+ return Err(invalid_input(format!( -+ "max_fuzzy_distance must be 0 for prepared FTS query contexts, got {max_fuzzy_distance}; fuzzy queries require a canonical prepared BM25 vocabulary" -+ ))); -+ } -+ let snapshot = unsafe { &*dataset }.snapshot(); -+ let inner = block_on(prepare_fts_query_context( -+ snapshot, -+ column, -+ query, -+ coverage_mode, -+ ))?; -+ Ok(Box::into_raw(Box::new(LanceFtsQueryContext { -+ inner: Arc::new(inner), -+ }))) -+} -+ -+/// Close a context handle. NULL-safe. Scanners that already attached the -+/// context retain their own shared reference. -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_fts_query_context_close(context: *mut LanceFtsQueryContext) { -+ if !context.is_null() { -+ swallow_unwind("lance_fts_query_context_close", || unsafe { -+ drop(Box::from_raw(context)); -+ }); -+ } -+} -+ -+pub(crate) unsafe fn clone_context( -+ context: *const LanceFtsQueryContext, -+) -> Result> { -+ if context.is_null() { -+ return Err(invalid_input("context must not be NULL")); -+ } -+ Ok(Arc::clone(&unsafe { &*context }.inner)) -+} -+ -+pub(crate) fn parse_segment_uuids(segment_uuids: *const u8, len: usize) -> Result> { -+ if segment_uuids.is_null() && len > 0 { -+ return Err(invalid_input( -+ "segment_uuids is NULL but len is greater than 0", -+ )); -+ } -+ if len > isize::MAX as usize / 16 { -+ return Err(invalid_input(format!( -+ "segment UUID count {len} exceeds the maximum addressable byte slice length" -+ ))); -+ } -+ let mut uuids = Vec::with_capacity(len); -+ for position in 0..len { -+ let mut bytes = [0_u8; 16]; -+ unsafe { -+ ptr::copy_nonoverlapping(segment_uuids.add(position * 16), bytes.as_mut_ptr(), 16); -+ } -+ uuids.push(Uuid::from_bytes(bytes)); -+ } -+ let unique: HashSet = uuids.iter().copied().collect(); -+ if unique.len() != uuids.len() { -+ return Err(invalid_input(format!( -+ "segment_uuids contains duplicate UUIDs; len={}, unique={}", -+ uuids.len(), -+ unique.len() -+ ))); -+ } -+ Ok(uuids) -+} -diff --git a/src/index_segment.rs b/src/index_segment.rs -index 9ffc8d0..a46c4f3 100644 ---- a/src/index_segment.rs -+++ b/src/index_segment.rs -@@ -22,7 +22,7 @@ use prost::Message; - use uuid::Uuid; - - use crate::dataset::LanceDataset; --use crate::error::{LanceErrorCode, clear_last_error, ffi_try, set_last_error}; -+use crate::error::{ffi_try, swallow_unwind}; - use crate::helpers; - use crate::index::{ - LanceMetricType, LanceScalarIndexType, LanceVectorIndexParams, LanceVectorIndexType, -@@ -1042,7 +1042,9 @@ pub unsafe extern "C" fn lance_free_bytes(bytes: *mut u8) { - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_index_segment_builder_free(builder: *mut LanceIndexSegmentBuilder) { - if !builder.is_null() { -- unsafe { drop(Box::from_raw(builder)) }; -+ swallow_unwind("lance_index_segment_builder_free", || unsafe { -+ drop(Box::from_raw(builder)); -+ }); - } - } - -@@ -1155,12 +1157,15 @@ unsafe fn metadata_uuid_inner( - pub unsafe extern "C" fn lance_index_segment_metadata_name( - metadata: *const LanceIndexSegmentMetadata, - ) -> *const c_char { -- if metadata.is_null() { -- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); -- return ptr::null(); -- } -- clear_last_error(); -- unsafe { (*metadata).name.as_ptr() } -+ ffi_try!( -+ (|| -> Result<*const c_char> { -+ if metadata.is_null() { -+ return Err(invalid_input("metadata is NULL")); -+ } -+ Ok(unsafe { (*metadata).name.as_ptr() }) -+ })(), -+ ptr::null() -+ ) - } - - /// Return the dataset version recorded in the metadata. -@@ -1168,12 +1173,15 @@ pub unsafe extern "C" fn lance_index_segment_metadata_name( - pub unsafe extern "C" fn lance_index_segment_metadata_dataset_version( - metadata: *const LanceIndexSegmentMetadata, - ) -> u64 { -- if metadata.is_null() { -- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); -- return 0; -- } -- clear_last_error(); -- unsafe { (*metadata).metadata.dataset_version } -+ ffi_try!( -+ (|| -> Result { -+ if metadata.is_null() { -+ return Err(invalid_input("metadata is NULL")); -+ } -+ Ok(unsafe { (*metadata).metadata.dataset_version }) -+ })(), -+ 0 -+ ) - } - - /// Return the physical index version recorded in the metadata. -@@ -1181,12 +1189,15 @@ pub unsafe extern "C" fn lance_index_segment_metadata_dataset_version( - pub unsafe extern "C" fn lance_index_segment_metadata_index_version( - metadata: *const LanceIndexSegmentMetadata, - ) -> i32 { -- if metadata.is_null() { -- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); -- return -1; -- } -- clear_last_error(); -- unsafe { (*metadata).metadata.index_version } -+ ffi_try!( -+ (|| -> Result { -+ if metadata.is_null() { -+ return Err(invalid_input("metadata is NULL")); -+ } -+ Ok(unsafe { (*metadata).metadata.index_version }) -+ })(), -+ neg -+ ) - } - - /// Return the concrete scalar/vector index enum value, or -1 on error. -@@ -1194,16 +1205,7 @@ pub unsafe extern "C" fn lance_index_segment_metadata_index_version( - pub unsafe extern "C" fn lance_index_segment_metadata_index_type( - metadata: *const LanceIndexSegmentMetadata, - ) -> i32 { -- match unsafe { metadata_index_type_inner(metadata) } { -- Ok(index_type) => { -- clear_last_error(); -- index_type -- } -- Err(error) => { -- crate::error::set_lance_error(&error); -- -1 -- } -- } -+ ffi_try!(unsafe { metadata_index_type_inner(metadata) }, neg) - } - - unsafe fn metadata_index_type_inner(metadata: *const LanceIndexSegmentMetadata) -> Result { -@@ -1258,19 +1260,20 @@ unsafe fn metadata_index_type_inner(metadata: *const LanceIndexSegmentMetadata) - pub unsafe extern "C" fn lance_index_segment_metadata_index_details_type_url( - metadata: *const LanceIndexSegmentMetadata, - ) -> *const c_char { -- if metadata.is_null() { -- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); -- return ptr::null(); -- } -- let Some(type_url) = (unsafe { &(*metadata).index_details_type_url }) else { -- set_last_error( -- LanceErrorCode::NotFound, -- "index metadata does not contain index_details", -- ); -- return ptr::null(); -- }; -- clear_last_error(); -- type_url.as_ptr() -+ ffi_try!( -+ (|| -> Result<*const c_char> { -+ if metadata.is_null() { -+ return Err(invalid_input("metadata is NULL")); -+ } -+ let type_url = unsafe { &(*metadata).index_details_type_url } -+ .as_ref() -+ .ok_or_else(|| { -+ Error::index_not_found("index metadata does not contain index_details") -+ })?; -+ Ok(type_url.as_ptr()) -+ })(), -+ ptr::null() -+ ) - } - - /// Return the number of indexed field IDs. -@@ -1278,12 +1281,15 @@ pub unsafe extern "C" fn lance_index_segment_metadata_index_details_type_url( - pub unsafe extern "C" fn lance_index_segment_metadata_field_count( - metadata: *const LanceIndexSegmentMetadata, - ) -> usize { -- if metadata.is_null() { -- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); -- return 0; -- } -- clear_last_error(); -- unsafe { (*metadata).metadata.fields.len() } -+ ffi_try!( -+ (|| -> Result { -+ if metadata.is_null() { -+ return Err(invalid_input("metadata is NULL")); -+ } -+ Ok(unsafe { (*metadata).metadata.fields.len() }) -+ })(), -+ 0 -+ ) - } - - /// Copy indexed field IDs in metadata order. -@@ -1334,12 +1340,15 @@ unsafe fn metadata_field_ids_inner( - pub unsafe extern "C" fn lance_index_segment_metadata_fragment_count( - metadata: *const LanceIndexSegmentMetadata, - ) -> usize { -- if metadata.is_null() { -- set_last_error(LanceErrorCode::InvalidArgument, "metadata is NULL"); -- return 0; -- } -- clear_last_error(); -- unsafe { (*metadata).fragment_ids.len() } -+ ffi_try!( -+ (|| -> Result { -+ if metadata.is_null() { -+ return Err(invalid_input("metadata is NULL")); -+ } -+ Ok(unsafe { (*metadata).fragment_ids.len() }) -+ })(), -+ 0 -+ ) - } - - /// Copy covered fragment IDs in ascending order. -@@ -1393,6 +1402,8 @@ pub unsafe extern "C" fn lance_index_segment_metadata_free( - metadata: *mut LanceIndexSegmentMetadata, - ) { - if !metadata.is_null() { -- unsafe { drop(Box::from_raw(metadata)) }; -+ swallow_unwind("lance_index_segment_metadata_free", || unsafe { -+ drop(Box::from_raw(metadata)); -+ }); - } - } -diff --git a/src/lib.rs b/src/lib.rs -index ed9cfe1..4d54641 100644 ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -15,6 +15,11 @@ - //! - The caller is responsible for freeing returned strings with `lance_free_string()`. - #![allow(clippy::missing_safety_doc)] - -+#[cfg(not(panic = "unwind"))] -+compile_error!( -+ "lance-c requires panic=\"unwind\" so its C ABI panic firewall can honor LANCE_ERR_PANIC" -+); -+ - mod add_columns; - mod alter_columns; - mod async_dispatcher; -@@ -26,6 +31,7 @@ mod delete; - mod drop_columns; - mod error; - mod fragment_writer; -+mod fts_query; - mod helpers; - mod index; - mod index_model; -@@ -52,6 +58,7 @@ pub use error::{ - LanceErrorCode, lance_free_string, lance_last_error_code, lance_last_error_message, - }; - pub use fragment_writer::*; -+pub use fts_query::*; - pub use index::*; - pub use index_model::*; - pub use index_segment::*; -diff --git a/src/scanner.rs b/src/scanner.rs -index ef9d290..f60f0c5 100644 ---- a/src/scanner.rs -+++ b/src/scanner.rs -@@ -6,28 +6,34 @@ - use std::ffi::{c_char, c_void}; - use std::pin::Pin; - use std::ptr; --use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; -+use std::sync::{Arc, Condvar, Mutex, Weak}; - use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; - - use arrow::ffi_stream::FFI_ArrowArrayStream; - use arrow_schema::SchemaRef; -+use datafusion::physical_plan::ExecutionPlan; - use futures::{FutureExt, Stream, StreamExt}; - use lance::Dataset; - use lance::dataset::scanner::{ - DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts, - }; -+use lance::io::exec::fts::MatchQueryExec; - use lance_core::Result; - use lance_index::scalar::FullTextSearchQuery; - use lance_io::stream::RecordBatchStream; -+use lance_table::format::IndexMetadata; - use uuid::Uuid; - - use crate::async_dispatcher::{self, LanceCallback}; - use crate::batch::LanceBatch; - use crate::dataset::LanceDataset; - use crate::error::{ -- LanceErrorCode, clear_last_error, error_code_from_lance, ffi_try, panic_payload_message, -- set_lance_error, set_last_error, swallow_unwind, -+ FfiFailure, LanceErrorCode, clear_last_error, error_code_from_lance, ffi_guard_with, ffi_try, -+ panic_payload_message, set_lance_error, set_last_error, swallow_unwind, -+}; -+use crate::fts_query::{ -+ FtsQueryContextInner, LanceFtsQueryContext, clone_context, parse_segment_uuids, - }; - use crate::helpers; - use crate::runtime::{RT, block_on}; -@@ -50,6 +56,7 @@ pub struct LanceScanner { - columns: Option>, - filter: Option, - substrait_filter: Option>, -+ additional_sql_filters: Vec, - limit: Option, - offset: Option, - batch_size: Option, -@@ -64,13 +71,20 @@ pub struct LanceScanner { - use_index: Option, - prefilter: bool, - fts_query: Option, -- // Set when a panic is caught in a stateful stream operation (issue #61): -+ fts_context: Option>, -+ fts_index_segments: Option>, -+ // Set when a panic is caught in any operation on this scanner (issue #61): - // once poisoned, every later `lance_scanner_*` call on this handle (except - // `lance_scanner_close`, which must always free memory) fails with - // `LANCE_ERR_PANIC`. Behind an `Arc` so the exported-stream wrapper and - // the spawned async task can poison the handle from outside this call - // frame via `poison_flag()`. - poisoned: Arc, -+ // Every RawWaker handed to the poll stream registers here. Close retires -+ // the registry before dropping the stream: pending callbacks are -+ // cancelled and callbacks already in progress are allowed to quiesce -+ // before the caller may destroy callback_ctx. -+ poll_wakers: PollWakerRegistry, - scan_statistics_callback: Option, - scan_started: AtomicBool, - // Materialized on first iteration call -@@ -111,6 +125,7 @@ impl LanceScanner { - columns: None, - filter: None, - substrait_filter: None, -+ additional_sql_filters: Vec::new(), - limit: None, - offset: None, - batch_size: None, -@@ -125,7 +140,10 @@ impl LanceScanner { - use_index: None, - prefilter: false, - fts_query: None, -+ fts_context: None, -+ fts_index_segments: None, - poisoned: Arc::new(AtomicBool::new(false)), -+ poll_wakers: PollWakerRegistry::default(), - scan_statistics_callback: None, - scan_started: AtomicBool::new(false), - stream: None, -@@ -161,86 +179,91 @@ impl LanceScanner { - Ok(()) - } - -- /// Build the underlying Scanner and open a stream. -- fn materialize_stream(&mut self) -> Result<()> { -- self.scan_started.store(true, Ordering::Release); -- let mut scanner = self.dataset.scan(); -- if let Some(cols) = &self.columns { -- scanner.project(cols)?; -- } -- // Substrait filter takes precedence over SQL filter when both are set. -- if let Some(bytes) = &self.substrait_filter { -- scanner.filter_substrait(bytes)?; -- } else if let Some(filter) = &self.filter { -- scanner.filter(filter)?; -- } -- if self.limit.is_some() || self.offset.is_some() { -- scanner.limit(self.limit, self.offset)?; -- } -- if let Some(bs) = self.batch_size { -- scanner.batch_size(bs); -- } -- if self.with_row_id { -- scanner.with_row_id(); -- } -- self.apply_fragment_filter(&mut scanner)?; -- if self.index_segments.is_some() && self.nearest.is_none() { -- return Err(lance_core::Error::invalid_input_source( -- "index_segments requires nearest() to be configured".into(), -- )); -+ /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the -+ /// selected committed segments. This is deliberately separate from -+ /// `fast_search`: that option is scanner-wide, changes unrelated scalar -+ /// index fallback behavior, and also forces `_rowid` into the output. -+ fn apply_prepared_fts_fragment_filter( -+ &self, -+ scanner: &mut lance::dataset::scanner::Scanner, -+ context: &FtsQueryContextInner, -+ segments: &[IndexMetadata], -+ ) -> Result<()> { -+ if !context.has_unindexed_fragments { -+ return Ok(()); - } -- // Lance validates fragment-scoped nearest searches when nearest() is -- // configured. Such searches are supported when the fragment scan is -- // the input to a prefilter, so this flag must be set first. -- if self.prefilter { -- scanner.prefilter(true); -+ -+ let mut selected_fragment_ids = std::collections::HashSet::new(); -+ for segment in segments { -+ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { -+ lance_core::Error::internal(format!( -+ "prepared FTS segment {} lost its validated fragment coverage", -+ segment.uuid -+ )) -+ })?; -+ selected_fragment_ids.extend(fragment_bitmap.iter()); - } -- if let Some(n) = &self.nearest { -- scanner.nearest(&n.column, n.query.as_ref(), n.k as usize)?; -- if let Some(np) = self.nprobes { -- scanner.nprobes(np as usize); -- } -- if let Some(rf) = self.refine_factor { -- scanner.refine(rf); -- } -- if let Some(ef) = self.ef { -- scanner.ef(ef as usize); -- } -- if let Some(m) = self.metric_override { -- scanner.distance_metric(m.to_distance()); -- } -- if let Some(ui) = self.use_index { -- scanner.use_index(ui); -- } -- if let Some(segments) = &self.index_segments { -- scanner.with_index_segments(segments.clone())?; -- } -+ -+ let selected_fragments = self -+ .dataset -+ .get_fragments() -+ .into_iter() -+ .filter(|fragment| { -+ u32::try_from(fragment.id()) -+ .is_ok_and(|fragment_id| selected_fragment_ids.contains(&fragment_id)) -+ }) -+ .map(|fragment| fragment.metadata().clone()) -+ .collect(); -+ scanner.with_fragments(selected_fragments); -+ Ok(()) -+ } -+ -+ fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { -+ if let Some(substrait) = &self.substrait_filter { -+ scanner.filter_substrait(substrait)?; -+ } else if let Some(sql) = &self.filter { -+ scanner.filter(sql)?; - } -- if let Some(fts) = &self.fts_query { -- scanner.full_text_search(fts.clone())?; -+ -+ if self.additional_sql_filters.is_empty() { -+ return Ok(()); - } -- if let Some(callback) = &self.scan_statistics_callback { -- scanner.scan_stats_callback(callback.clone()); -+ -+ // Let Lance resolve every SQL expression against the scanner's full -+ // filterable schema. Besides stored columns, this includes metadata -+ // columns and query-generated columns such as _distance and _score. -+ let mut combined = scanner.get_expr_filter()?; -+ for sql in &self.additional_sql_filters { -+ let mut additional_scanner = scanner.clone(); -+ additional_scanner.filter(sql)?; -+ let additional = additional_scanner -+ .get_expr_filter()? -+ .expect("additional SQL filter exists"); -+ combined = Some(match combined { -+ Some(existing) => existing.and(additional), -+ None => additional, -+ }); - } -- let stream = block_on(scanner.try_into_stream())?; -+ scanner.filter_expr(combined.expect("additional SQL filter exists")); -+ Ok(()) -+ } -+ -+ /// Build the underlying Scanner and open a stream. -+ fn materialize_stream(&mut self) -> Result<()> { -+ let prepared_scanner = self.build_scanner()?; -+ let stream = block_on(prepared_scanner.try_into_stream())?; - self.schema = Some(stream.schema()); - self.stream = Some(Box::pin(stream)); - Ok(()) - } - - /// Build a Scanner (without materializing) and return it. -- fn build_scanner(&self) -> Result { -+ fn build_scanner(&self) -> Result { - self.scan_started.store(true, Ordering::Release); - let mut scanner = self.dataset.scan(); - if let Some(cols) = &self.columns { - scanner.project(cols)?; - } -- // Substrait filter takes precedence over SQL filter when both are set. -- if let Some(bytes) = &self.substrait_filter { -- scanner.filter_substrait(bytes)?; -- } else if let Some(filter) = &self.filter { -- scanner.filter(filter)?; -- } - if self.limit.is_some() || self.offset.is_some() { - scanner.limit(self.limit, self.offset)?; - } -@@ -256,6 +279,16 @@ impl LanceScanner { - "index_segments requires nearest() to be configured".into(), - )); - } -+ if self.fts_index_segments.is_some() && self.fts_context.is_none() { -+ return Err(lance_core::Error::invalid_input_source( -+ "fts_index_segments requires an FTS query context".into(), -+ )); -+ } -+ if self.fts_context.is_some() && self.fragment_ids.is_some() { -+ return Err(lance_core::Error::invalid_input_source( -+ "fragment_ids cannot be combined with an FTS query context; split the query by FTS index segment UUID instead".into(), -+ )); -+ } - // nearest() checks the current prefilter setting before accepting a - // fragment-scoped search. Enable it before installing the query. - if self.prefilter { -@@ -285,11 +318,139 @@ impl LanceScanner { - if let Some(fts) = &self.fts_query { - scanner.full_text_search(fts.clone())?; - } -+ let distributed_fts = if let Some(context) = &self.fts_context { -+ context.validate_dataset_identity(&self.dataset)?; -+ let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; -+ self.apply_prepared_fts_fragment_filter(&mut scanner, context, &segments)?; -+ scanner.full_text_search(context.query.clone())?; -+ Some(PreparedFtsExecution { -+ context: Arc::clone(context), -+ segments, -+ batch_size: self.batch_size, -+ scan_statistics_callback: self.scan_statistics_callback.clone(), -+ }) -+ } else { -+ None -+ }; -+ self.apply_filter(&mut scanner)?; - if let Some(callback) = &self.scan_statistics_callback { - scanner.scan_stats_callback(callback.clone()); - } -- Ok(scanner) -+ Ok(PreparedScanner { -+ scanner, -+ distributed_fts, -+ }) -+ } -+} -+ -+struct PreparedFtsExecution { -+ context: Arc, -+ segments: Vec, -+ batch_size: Option, -+ scan_statistics_callback: Option, -+} -+ -+struct PreparedScanner { -+ scanner: lance::dataset::scanner::Scanner, -+ distributed_fts: Option, -+} -+ -+impl PreparedScanner { -+ async fn try_into_stream(self) -> Result { -+ let Some(distributed_fts) = self.distributed_fts else { -+ return self.scanner.try_into_stream().await; -+ }; -+ let plan = self.scanner.create_plan().await?; -+ let (plan, replaced) = replace_match_query_exec( -+ plan, -+ &distributed_fts.segments, -+ &distributed_fts.context.scorer, -+ )?; -+ if replaced != 1 { -+ return Err(lance_core::Error::internal(format!( -+ "expected exactly one MatchQueryExec in prepared FTS plan, replaced {replaced}" -+ ))); -+ } -+ let stream = lance_datafusion::exec::execute_plan( -+ plan, -+ lance_datafusion::exec::LanceExecutionOptions { -+ batch_size: distributed_fts.batch_size, -+ execution_stats_callback: distributed_fts.scan_statistics_callback, -+ ..Default::default() -+ }, -+ )?; -+ Ok(DatasetRecordBatchStream::new(stream)) -+ } -+} -+ -+fn select_fts_segments( -+ context: &FtsQueryContextInner, -+ selected_uuids: Option<&[Uuid]>, -+) -> Result> { -+ let Some(selected_uuids) = selected_uuids else { -+ return Ok(context.segments.clone()); -+ }; -+ let mut selected = Vec::with_capacity(selected_uuids.len()); -+ for uuid in selected_uuids { -+ let segment = context -+ .segments -+ .iter() -+ .find(|segment| segment.uuid == *uuid) -+ .ok_or_else(|| { -+ lance_core::Error::invalid_input_source( -+ format!( -+ "FTS segment UUID {uuid} is not present in the attached query context for dataset version {}", -+ context.dataset.version_id() -+ ) -+ .into(), -+ ) -+ })?; -+ selected.push(segment.clone()); -+ } -+ if selected.is_empty() { -+ return Err(lance_core::Error::invalid_input_source( -+ "FTS segment subset must contain at least one UUID".into(), -+ )); -+ } -+ Ok(selected) -+} -+ -+fn replace_match_query_exec( -+ plan: Arc, -+ segments: &[IndexMetadata], -+ scorer: &Arc, -+) -> Result<(Arc, usize)> { -+ let children = plan.children(); -+ let mut replaced = 0; -+ let rebuilt = if children.is_empty() { -+ plan -+ } else { -+ let mut new_children = Vec::with_capacity(children.len()); -+ for child in children { -+ let (new_child, child_replaced) = -+ replace_match_query_exec(Arc::clone(child), segments, scorer)?; -+ new_children.push(new_child); -+ replaced += child_replaced; -+ } -+ plan.with_new_children(new_children).map_err(|error| { -+ lance_core::Error::internal(format!( -+ "failed to rebuild FTS execution plan children: {error}" -+ )) -+ })? -+ }; -+ -+ if let Some(exec) = rebuilt.downcast_ref::() { -+ let replacement = MatchQueryExec::new_with_segments( -+ Arc::clone(exec.dataset()), -+ exec.query().clone(), -+ exec.params().clone(), -+ exec.prefilter_source().clone(), -+ segments.to_vec(), -+ ) -+ .with_base_scorer(Arc::clone(scorer)); -+ return Ok((Arc::new(replacement), replaced + 1)); - } -+ Ok((rebuilt, replaced)) - } - - /// Type of a dynamically named scan metric. -@@ -442,6 +603,26 @@ macro_rules! scanner_poison_check { - }; - } - -+/// Run a scanner configuration call through the common FFI guard and poison -+/// the handle if that call catches a panic. Regular Lance errors remain -+/// recoverable and do not poison the builder. -+macro_rules! scanner_ffi_try { -+ ($scanner:expr, $body:expr $(,)?) => {{ -+ let scanner_ptr = $scanner; -+ ffi_guard_with( -+ || $body, -+ |failure| { -+ if matches!(failure, FfiFailure::Panic) && !scanner_ptr.is_null() { -+ unsafe { &*scanner_ptr } -+ .poison_flag() -+ .store(true, Ordering::SeqCst); -+ } -+ -1 -+ }, -+ ) -+ }}; -+} -+ - // --------------------------------------------------------------------------- - // Scanner lifecycle + builder - // --------------------------------------------------------------------------- -@@ -486,7 +667,7 @@ unsafe fn scanner_new_inner( - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_set_limit(scanner: *mut LanceScanner, limit: i64) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!(unsafe { scanner_set_limit_inner(scanner, limit) }, neg) -+ scanner_ffi_try!(scanner, unsafe { scanner_set_limit_inner(scanner, limit) }) - } - - unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Result { -@@ -504,7 +685,9 @@ unsafe fn scanner_set_limit_inner(scanner: *mut LanceScanner, limit: i64) -> Res - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_set_offset(scanner: *mut LanceScanner, offset: i64) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!(unsafe { scanner_set_offset_inner(scanner, offset) }, neg) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_offset_inner(scanner, offset) -+ }) - } - - unsafe fn scanner_set_offset_inner(scanner: *mut LanceScanner, offset: i64) -> Result { -@@ -525,10 +708,9 @@ pub unsafe extern "C" fn lance_scanner_set_batch_size( - batch_size: i64, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { scanner_set_batch_size_inner(scanner, batch_size) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_batch_size_inner(scanner, batch_size) -+ }) - } - - unsafe fn scanner_set_batch_size_inner(scanner: *mut LanceScanner, batch_size: i64) -> Result { -@@ -549,7 +731,9 @@ pub unsafe extern "C" fn lance_scanner_with_row_id( - enable: bool, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!(unsafe { scanner_with_row_id_inner(scanner, enable) }, neg) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_with_row_id_inner(scanner, enable) -+ }) - } - - unsafe fn scanner_with_row_id_inner(scanner: *mut LanceScanner, enable: bool) -> Result { -@@ -574,10 +758,9 @@ pub unsafe extern "C" fn lance_scanner_set_fragment_ids( - len: usize, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { scanner_set_fragment_ids_inner(scanner, ids, len) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_fragment_ids_inner(scanner, ids, len) -+ }) - } - - unsafe fn scanner_set_fragment_ids_inner( -@@ -631,10 +814,9 @@ pub unsafe extern "C" fn lance_scanner_set_substrait_filter( - len: usize, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { scanner_set_substrait_filter_inner(scanner, bytes, len) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_substrait_filter_inner(scanner, bytes, len) -+ }) - } - - unsafe fn scanner_set_substrait_filter_inner( -@@ -663,6 +845,50 @@ unsafe fn scanner_set_substrait_filter_inner( - Ok(0) - } - -+/// Add an SQL filter that is combined with the scanner's selected primary filter using AND. -+/// -+/// The primary filter is the Substrait filter when one is set, otherwise it is the SQL filter -+/// passed to `lance_scanner_new`. Multiple additional SQL filters are also combined using AND. -+/// This must be called before the scan starts. The string is copied into the scanner. -+/// -+/// Returns 0 on success, -1 on error (check `lance_last_error_*`). -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_scanner_additional_sql_filter( -+ scanner: *mut LanceScanner, -+ filter: *const c_char, -+) -> i32 { -+ scanner_poison_check!(scanner, -1); -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_additional_sql_filter_inner(scanner, filter) -+ }) -+} -+ -+unsafe fn scanner_additional_sql_filter_inner( -+ scanner: *mut LanceScanner, -+ filter: *const c_char, -+) -> Result { -+ if scanner.is_null() { -+ return Err(lance_core::Error::invalid_input_source( -+ "scanner is NULL".into(), -+ )); -+ } -+ let filter = unsafe { helpers::parse_c_string(filter)? } -+ .ok_or_else(|| lance_core::Error::invalid_input_source("filter must not be NULL".into()))?; -+ if filter.is_empty() { -+ return Err(lance_core::Error::invalid_input_source( -+ "additional SQL filter must be non-empty".into(), -+ )); -+ } -+ let scanner = unsafe { &mut *scanner }; -+ if scanner.scan_started.load(Ordering::Acquire) { -+ return Err(lance_core::Error::invalid_input_source( -+ "additional SQL filter must be set before the scan starts".into(), -+ )); -+ } -+ scanner.additional_sql_filters.push(filter.to_string()); -+ Ok(0) -+} -+ - /// Register a callback that receives execution statistics after the scan stream - /// is fully consumed to EOF. - /// -@@ -693,10 +919,9 @@ pub unsafe extern "C" fn lance_scanner_set_statistics_callback( - callback_ctx: *mut c_void, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { scanner_set_statistics_callback_inner(scanner, callback, callback_ctx) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_statistics_callback_inner(scanner, callback, callback_ctx) -+ }) - } - - unsafe fn scanner_set_statistics_callback_inner( -@@ -734,6 +959,12 @@ unsafe fn scanner_set_statistics_callback_inner( - - /// Close and free a scanner handle. - /// -+/// Pending poll wakers are cancelled before the stream is dropped. If a poll -+/// waker callback is already running on another thread, close waits for that -+/// callback to return, making this function the retirement boundary for its -+/// callback context. A waker callback must therefore never close or otherwise -+/// re-enter its originating scanner. -+/// - /// Best-effort (issue #61): this drops a possibly-live - /// `DatasetRecordBatchStream`, the highest-risk `Drop` in this crate. A - /// panic raised while dropping the handle is caught and logged rather than -@@ -743,7 +974,9 @@ unsafe fn scanner_set_statistics_callback_inner( - pub unsafe extern "C" fn lance_scanner_close(scanner: *mut LanceScanner) { - if !scanner.is_null() { - swallow_unwind("lance_scanner_close", || unsafe { -- let _ = Box::from_raw(scanner); -+ let scanner = Box::from_raw(scanner); -+ scanner.poll_wakers.retire_and_wait(); -+ drop(scanner); - }); - } - } -@@ -770,27 +1003,30 @@ pub unsafe extern "C" fn lance_scanner_to_arrow_stream( - scanner: *mut LanceScanner, - out: *mut FFI_ArrowArrayStream, - ) -> i32 { -- if scanner.is_null() || out.is_null() { -- set_last_error( -- LanceErrorCode::InvalidArgument, -- "scanner and out must not be NULL", -- ); -+ if scanner.is_null() { -+ set_last_error(LanceErrorCode::InvalidArgument, "scanner must not be NULL"); - return -1; - } - scanner_poison_check!(scanner, -1); -+ if out.is_null() { -+ set_last_error(LanceErrorCode::InvalidArgument, "out must not be NULL"); -+ return -1; -+ } - let s = unsafe { &*scanner }; - let poisoned = s.poison_flag(); -- match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { -- scanner_to_arrow_stream_inner(s, out) -- })) { -- Ok(Ok(rc)) => { -- clear_last_error(); -- rc -- } -- Ok(Err(err)) => { -- set_lance_error(&err); -- -1 -+ match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { -+ match unsafe { scanner_to_arrow_stream_inner(s, out) } { -+ Ok(rc) => { -+ clear_last_error(); -+ rc -+ } -+ Err(err) => { -+ set_lance_error(&err); -+ -1 -+ } - } -+ })) { -+ Ok(rc) => rc, - Err(payload) => { - poisoned.store(true, Ordering::SeqCst); - set_last_error( -@@ -848,14 +1084,18 @@ pub unsafe extern "C" fn lance_scanner_next( - scanner: *mut LanceScanner, - out: *mut *mut LanceBatch, - ) -> i32 { -- if scanner.is_null() || out.is_null() { -- set_last_error( -- LanceErrorCode::InvalidArgument, -- "scanner and out must not be NULL", -- ); -+ if !out.is_null() { -+ unsafe { *out = ptr::null_mut() }; -+ } -+ if scanner.is_null() { -+ set_last_error(LanceErrorCode::InvalidArgument, "scanner must not be NULL"); - return -1; - } - scanner_poison_check!(scanner, -1); -+ if out.is_null() { -+ set_last_error(LanceErrorCode::InvalidArgument, "out must not be NULL"); -+ return -1; -+ } - let s = unsafe { &mut *scanner }; - let poisoned = s.poison_flag(); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { -@@ -919,16 +1159,22 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> - /// Start an async scan. The callback is invoked on a dedicated dispatcher thread - /// when the ArrowArrayStream is ready. - /// --/// - `callback`: Called with `(ctx, 0, *mut ArrowArrayStream)` on success, --/// or `(ctx, -1, NULL)` on error. On error, the dispatcher installs the --/// error on the callback thread's TLS first, so `lance_last_error_*` --/// called from inside the callback observes the failure. -+/// - `callback`: Must not be NULL. Called with -+/// `(ctx, 0, *mut ArrowArrayStream)` on success or `(ctx, -1, NULL)` on -+/// error. The successful result is a Rust-allocated outer stream container -+/// and must eventually be passed to [`lance_scanner_async_stream_free`]. On -+/// error, the dispatcher installs the error on the callback thread's TLS -+/// first, so `lance_last_error_*` called from inside the callback observes -+/// the failure. - /// - `callback_ctx`: Opaque pointer passed back to the callback. - /// - /// The scanner configuration is captured at call time. The scanner handle - /// can be closed immediately after this call. - /// --/// The promised contract is exactly one callback completion, even on panic. -+/// With a non-NULL callback, the promised contract is exactly one completion, -+/// even on panic. Completions normally run on the dispatcher thread; if that -+/// thread cannot be created or its channel has failed, delivery falls back to -+/// the thread producing the completion rather than dropping it. - /// A panic anywhere in call-time setup (validation, scanner building, - /// runtime access, task spawn) is caught by the entry guard below and still - /// reported through the callback: `(ctx, -1, NULL)` with `LANCE_ERR_PANIC`, -@@ -939,9 +1185,13 @@ unsafe fn scanner_next_inner(s: &mut LanceScanner, out: *mut *mut LanceBatch) -> - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_scan_async( - scanner: *const LanceScanner, -- callback: LanceCallback, -+ callback: Option, - callback_ctx: *mut c_void, - ) { -+ let Some(callback) = callback else { -+ set_last_error(LanceErrorCode::InvalidArgument, "callback must not be NULL"); -+ return; -+ }; - unsafe { - scan_async_guarded(scanner, callback, callback_ctx, |s, cb, ctx| { - scan_async_setup(s, cb, ctx) -@@ -1148,6 +1398,22 @@ unsafe fn scan_async_setup( - }); - } - -+/// Release the heap-allocated Arrow stream container returned through a -+/// successful [`lance_scanner_scan_async`] callback. -+/// -+/// The Arrow stream's own `release` callback is invoked first when it is still -+/// present, then the outer Rust allocation is freed. Passing NULL is a no-op. -+/// This function must not be used for stack-allocated streams returned by -+/// [`lance_scanner_to_arrow_stream`]. -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_scanner_async_stream_free(stream: *mut FFI_ArrowArrayStream) { -+ if !stream.is_null() { -+ swallow_unwind("lance_scanner_async_stream_free", || unsafe { -+ drop(Box::from_raw(stream)); -+ }); -+ } -+} -+ - // --------------------------------------------------------------------------- - // Poll-based iteration (for cooperative async runtimes) - // --------------------------------------------------------------------------- -@@ -1155,9 +1421,13 @@ unsafe fn scan_async_setup( - /// Poll for the next batch without blocking. - /// - /// - If data is already buffered, returns `LANCE_POLL_READY` immediately. --/// - If I/O is needed, returns `LANCE_POLL_PENDING` and schedules the waker callback. -+/// - If I/O is needed, returns `LANCE_POLL_PENDING` and schedules the non-NULL -+/// waker callback. - /// The caller should yield the thread and re-poll after the waker fires. - /// - The waker is single-use: it fires at most once per poll call that returns PENDING. -+/// Its context must remain valid until the callback returns or -+/// `lance_scanner_close` returns. Close cancels callbacks that have not -+/// entered and waits for callbacks already in progress. - /// - /// The stream is lazily materialized on the first poll call (which will typically - /// return PENDING while the stream opens). -@@ -1168,18 +1438,26 @@ unsafe fn scan_async_setup( - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_poll_next( - scanner: *mut LanceScanner, -- waker: LanceWaker, -+ waker: Option, - waker_ctx: *mut c_void, - out: *mut *mut LanceBatch, - ) -> LancePollStatus { -- if scanner.is_null() || out.is_null() { -- set_last_error( -- LanceErrorCode::InvalidArgument, -- "scanner and out must not be NULL", -- ); -+ if !out.is_null() { -+ unsafe { *out = ptr::null_mut() }; -+ } -+ if scanner.is_null() { -+ set_last_error(LanceErrorCode::InvalidArgument, "scanner must not be NULL"); - return LancePollStatus::Error; - } - scanner_poison_check!(scanner, LancePollStatus::Error); -+ if out.is_null() { -+ set_last_error(LanceErrorCode::InvalidArgument, "out must not be NULL"); -+ return LancePollStatus::Error; -+ } -+ let Some(waker) = waker else { -+ set_last_error(LanceErrorCode::InvalidArgument, "waker must not be NULL"); -+ return LancePollStatus::Error; -+ }; - let s = unsafe { &mut *scanner }; - let poisoned = s.poison_flag(); - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { -@@ -1220,13 +1498,13 @@ unsafe fn scanner_poll_next_inner( - return LancePollStatus::Error; - } - -- let stream = s.stream.as_mut().unwrap(); -- - // Construct a std::task::Waker from the C function pointer. -- let raw_waker = make_raw_waker(waker, waker_ctx); -+ let raw_waker = make_raw_waker(&s.poll_wakers, waker, waker_ctx); - let waker_obj = unsafe { Waker::from_raw(raw_waker) }; - let mut cx = Context::from_waker(&waker_obj); - -+ let stream = s.stream.as_mut().unwrap(); -+ - // Enter the Tokio runtime context so internal I/O futures can access - // the reactor. Without this, polling from a non-Tokio thread panics. - let _guard = RT.enter(); -@@ -1264,39 +1542,66 @@ unsafe fn scanner_poll_next_inner( - struct CWakerContext { - waker_fn: LanceWaker, - ctx: *mut c_void, -+ state: Mutex, -+ quiesced: Condvar, -+} -+ -+#[derive(Default)] -+struct CWakerState { -+ fired: bool, -+ cancelled: bool, -+ active: bool, -+} -+ -+#[derive(Default)] -+struct PollWakerRegistry { -+ state: Mutex, -+} -+ -+#[derive(Default)] -+struct PollWakerRegistryState { -+ retired: bool, -+ registrations: Vec>, - } - - // C function pointers + void* are Send by convention for FFI. - unsafe impl Send for CWakerContext {} - unsafe impl Sync for CWakerContext {} - --fn make_raw_waker(waker_fn: LanceWaker, ctx: *mut c_void) -> RawWaker { -- let data = Box::into_raw(Box::new(CWakerContext { waker_fn, ctx })) as *const (); -+fn make_raw_waker( -+ registry: &PollWakerRegistry, -+ waker_fn: LanceWaker, -+ ctx: *mut c_void, -+) -> RawWaker { -+ let context = Arc::new(CWakerContext { -+ waker_fn, -+ ctx, -+ state: Mutex::new(CWakerState::default()), -+ quiesced: Condvar::new(), -+ }); -+ registry.register(&context); -+ let data = Arc::into_raw(context) as *const (); - - const VTABLE: RawWakerVTable = RawWakerVTable::new( - // clone - |data| { -- let orig = unsafe { &*(data as *const CWakerContext) }; -- let cloned = Box::new(CWakerContext { -- waker_fn: orig.waker_fn, -- ctx: orig.ctx, -- }); -- RawWaker::new(Box::into_raw(cloned) as *const (), &VTABLE) -+ unsafe { Arc::::increment_strong_count(data.cast()) }; -+ RawWaker::new(data, &VTABLE) - }, - // wake (consumes) - |data| { -- let ctx = unsafe { Box::from_raw(data as *mut CWakerContext) }; -- unsafe { (ctx.waker_fn)(ctx.ctx) }; -+ let ctx = unsafe { Arc::from_raw(data as *const CWakerContext) }; -+ ctx.wake_once(); - }, - // wake_by_ref - |data| { - let ctx = unsafe { &*(data as *const CWakerContext) }; -- unsafe { (ctx.waker_fn)(ctx.ctx) }; -+ ctx.wake_once(); - }, - // drop - |data| { - unsafe { -- let _ = Box::from_raw(data as *mut CWakerContext); -+ drop(Arc::from_raw(data as *const CWakerContext)); - }; - }, - ); -@@ -1304,6 +1609,99 @@ fn make_raw_waker(waker_fn: LanceWaker, ctx: *mut c_void) -> RawWaker { - RawWaker::new(data, &VTABLE) - } - -+impl CWakerContext { -+ fn wake_once(&self) { -+ { -+ let mut state = self -+ .state -+ .lock() -+ .unwrap_or_else(|poisoned| poisoned.into_inner()); -+ if state.cancelled || state.fired { -+ return; -+ } -+ state.fired = true; -+ state.active = true; -+ } -+ -+ unsafe { (self.waker_fn)(self.ctx) }; -+ -+ let mut state = self -+ .state -+ .lock() -+ .unwrap_or_else(|poisoned| poisoned.into_inner()); -+ state.active = false; -+ self.quiesced.notify_all(); -+ } -+ -+ fn cancel(&self) { -+ self.state -+ .lock() -+ .unwrap_or_else(|poisoned| poisoned.into_inner()) -+ .cancelled = true; -+ } -+ -+ fn wait_until_quiescent(&self) { -+ let mut state = self -+ .state -+ .lock() -+ .unwrap_or_else(|poisoned| poisoned.into_inner()); -+ while state.active { -+ state = self -+ .quiesced -+ .wait(state) -+ .unwrap_or_else(|poisoned| poisoned.into_inner()); -+ } -+ } -+} -+ -+impl PollWakerRegistry { -+ fn register(&self, registration: &Arc) { -+ let retired = { -+ let mut state = self -+ .state -+ .lock() -+ .unwrap_or_else(|poisoned| poisoned.into_inner()); -+ state -+ .registrations -+ .retain(|candidate| candidate.strong_count() > 0); -+ if state.retired { -+ true -+ } else { -+ state.registrations.push(Arc::downgrade(registration)); -+ false -+ } -+ }; -+ if retired { -+ registration.cancel(); -+ } -+ } -+ -+ fn retire_and_wait(&self) { -+ let registrations = { -+ let mut state = self -+ .state -+ .lock() -+ .unwrap_or_else(|poisoned| poisoned.into_inner()); -+ state.retired = true; -+ state -+ .registrations -+ .drain(..) -+ .filter_map(|registration| registration.upgrade()) -+ .collect::>() -+ }; -+ -+ // Cancel every registration before waiting for any one callback, so -+ // no later registration can enter while close is quiescing an earlier -+ // one. -+ for registration in ®istrations { -+ registration.cancel(); -+ } -+ for registration in registrations { -+ registration.wait_until_quiescent(); -+ } -+ } -+} -+ - // --------------------------------------------------------------------------- - // Vector search (Phase 2): setter knobs - // --------------------------------------------------------------------------- -@@ -1313,7 +1711,8 @@ macro_rules! scanner_set_u32 { - #[unsafe(no_mangle)] - pub unsafe extern "C" fn $name(scanner: *mut LanceScanner, value: u32) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -+ scanner_ffi_try!( -+ scanner, - (|| -> Result { - if scanner.is_null() { - return Err(lance_core::Error::invalid_input_source( -@@ -1324,8 +1723,7 @@ macro_rules! scanner_set_u32 { - (*scanner).$field = Some(value); - } - Ok(0) -- })(), -- neg -+ })() - ) - } - }; -@@ -1338,7 +1736,9 @@ scanner_set_u32!(lance_scanner_set_ef, ef); - #[unsafe(no_mangle)] - pub unsafe extern "C" fn lance_scanner_set_metric(scanner: *mut LanceScanner, metric: i32) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!(unsafe { scanner_set_metric_inner(scanner, metric) }, neg) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_metric_inner(scanner, metric) -+ }) - } - - unsafe fn scanner_set_metric_inner(scanner: *mut LanceScanner, metric: i32) -> Result { -@@ -1370,7 +1770,9 @@ pub unsafe extern "C" fn lance_scanner_set_use_index( - enable: bool, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!(unsafe { scanner_set_use_index_inner(scanner, enable) }, neg) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_use_index_inner(scanner, enable) -+ }) - } - - unsafe fn scanner_set_use_index_inner(scanner: *mut LanceScanner, enable: bool) -> Result { -@@ -1391,7 +1793,9 @@ pub unsafe extern "C" fn lance_scanner_set_prefilter( - enable: bool, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!(unsafe { scanner_set_prefilter_inner(scanner, enable) }, neg) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_prefilter_inner(scanner, enable) -+ }) - } - - unsafe fn scanner_set_prefilter_inner(scanner: *mut LanceScanner, enable: bool) -> Result { -@@ -1424,10 +1828,9 @@ pub unsafe extern "C" fn lance_scanner_set_index_segments( - len: usize, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { scanner_set_index_segments_inner(scanner, segment_uuids, len) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_index_segments_inner(scanner, segment_uuids, len) -+ }) - } - - unsafe fn scanner_set_index_segments_inner( -@@ -1485,10 +1888,9 @@ pub unsafe extern "C" fn lance_scanner_nearest( - k: u32, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { scanner_nearest_inner(scanner, column, query_data, query_len, element_type, k) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_nearest_inner(scanner, column, query_data, query_len, element_type, k) -+ },) - } - - unsafe fn scanner_nearest_inner( -@@ -1510,9 +1912,9 @@ unsafe fn scanner_nearest_inner( - )); - } - let s = unsafe { &mut *scanner }; -- if s.fts_query.is_some() { -+ if s.fts_query.is_some() || s.fts_context.is_some() { - return Err(lance_core::Error::invalid_input_source( -- "cannot call nearest after full_text_search; they are mutually exclusive".into(), -+ "cannot call nearest after full_text_search or attaching an FTS query context; they are mutually exclusive".into(), - )); - } - let column_str = unsafe { helpers::parse_c_string(column)? }.unwrap(); -@@ -1586,10 +1988,9 @@ pub unsafe extern "C" fn lance_scanner_full_text_search( - max_fuzzy_distance: u32, - ) -> i32 { - scanner_poison_check!(scanner, -1); -- ffi_try!( -- unsafe { fts_inner(scanner, query, columns, max_fuzzy_distance) }, -- neg -- ) -+ scanner_ffi_try!(scanner, unsafe { -+ fts_inner(scanner, query, columns, max_fuzzy_distance) -+ },) - } - - unsafe fn fts_inner( -@@ -1611,6 +2012,11 @@ unsafe fn fts_inner( - "cannot call full_text_search after nearest; they are mutually exclusive".into(), - )); - } -+ if s.fts_context.is_some() { -+ return Err(lance_core::Error::invalid_input_source( -+ "cannot call full_text_search after attaching an FTS query context; the context already owns the query".into(), -+ )); -+ } - - let query_str = unsafe { helpers::parse_c_string(query)? } - .unwrap() -@@ -1633,13 +2039,89 @@ unsafe fn fts_inner( - Ok(0) - } - -+/// Attach an immutable, process-local FTS query context to this scanner. -+/// The scanner clones the context's shared ownership; the caller may close -+/// the public context handle after this function returns successfully. -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_scanner_set_fts_query_context( -+ scanner: *mut LanceScanner, -+ context: *const LanceFtsQueryContext, -+) -> i32 { -+ scanner_poison_check!(scanner, -1); -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_fts_query_context_inner(scanner, context) -+ }) -+} -+ -+unsafe fn scanner_set_fts_query_context_inner( -+ scanner: *mut LanceScanner, -+ context: *const LanceFtsQueryContext, -+) -> Result { -+ if scanner.is_null() { -+ return Err(lance_core::Error::invalid_input_source( -+ "scanner must not be NULL".into(), -+ )); -+ } -+ let context = unsafe { clone_context(context)? }; -+ let scanner = unsafe { &mut *scanner }; -+ if scanner.nearest.is_some() { -+ return Err(lance_core::Error::invalid_input_source( -+ "cannot attach an FTS query context after nearest; they are mutually exclusive".into(), -+ )); -+ } -+ if scanner.fts_query.is_some() { -+ return Err(lance_core::Error::invalid_input_source( -+ "cannot attach an FTS query context after full_text_search; the context already owns the query" -+ .into(), -+ )); -+ } -+ context.validate_dataset_identity(&scanner.dataset)?; -+ scanner.fts_context = Some(context); -+ Ok(0) -+} -+ -+/// Restrict a context-backed FTS scan to a subset of segment UUIDs. -+/// Passing `len == 0` clears the restriction so all context segments are used. -+#[unsafe(no_mangle)] -+pub unsafe extern "C" fn lance_scanner_set_fts_index_segments( -+ scanner: *mut LanceScanner, -+ segment_uuids: *const u8, -+ len: usize, -+) -> i32 { -+ scanner_poison_check!(scanner, -1); -+ scanner_ffi_try!(scanner, unsafe { -+ scanner_set_fts_index_segments_inner(scanner, segment_uuids, len) -+ }) -+} -+ -+unsafe fn scanner_set_fts_index_segments_inner( -+ scanner: *mut LanceScanner, -+ segment_uuids: *const u8, -+ len: usize, -+) -> Result { -+ if scanner.is_null() { -+ return Err(lance_core::Error::invalid_input_source( -+ "scanner must not be NULL".into(), -+ )); -+ } -+ let segments = if len == 0 { -+ None -+ } else { -+ Some(parse_segment_uuids(segment_uuids, len)?) -+ }; -+ unsafe { &mut *scanner }.fts_index_segments = segments; -+ Ok(0) -+} -+ - #[cfg(test)] - mod tests { - use super::*; - use crate::dataset::{lance_dataset_close, lance_dataset_open}; - use crate::error::{lance_last_error_code, lance_last_error_message}; - use std::ffi::{CStr, CString}; -- use std::sync::atomic::AtomicI32; -+ use std::sync::atomic::{AtomicI32, AtomicUsize}; -+ use std::sync::{Barrier, mpsc}; -+ use std::time::Duration; - - use arrow_array::{Int32Array, RecordBatch, StringArray}; - use arrow_schema::{DataType, Field, Schema}; -@@ -1706,6 +2188,198 @@ mod tests { - - unsafe extern "C" fn noop_waker(_ctx: *mut c_void) {} - -+ #[test] -+ fn null_async_callback_is_rejected_without_poisoning_scanner() { -+ let (_tmp, uri) = create_test_dataset(); -+ let (dataset, scanner) = open_dataset_and_scanner(&uri); -+ -+ unsafe { lance_scanner_scan_async(scanner, None, ptr::null_mut()) }; -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ let msg_ptr = lance_last_error_message(); -+ assert!(!msg_ptr.is_null()); -+ let msg = unsafe { CStr::from_ptr(msg_ptr) }.to_string_lossy(); -+ assert!(msg.contains("callback must not be NULL"), "got: {msg}"); -+ unsafe { crate::error::lance_free_string(msg_ptr) }; -+ assert!(!unsafe { &*scanner }.is_poisoned()); -+ -+ unsafe { -+ lance_scanner_close(scanner); -+ lance_dataset_close(dataset); -+ } -+ } -+ -+ #[test] -+ fn null_poll_waker_is_rejected_and_clears_out() { -+ let (_tmp, uri) = create_test_dataset(); -+ let (dataset, scanner) = open_dataset_and_scanner(&uri); -+ let mut batch = std::ptr::NonNull::::dangling().as_ptr(); -+ -+ let status = unsafe { lance_scanner_poll_next(scanner, None, ptr::null_mut(), &mut batch) }; -+ assert_eq!(status, LancePollStatus::Error); -+ assert!(batch.is_null()); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ assert!(!unsafe { &*scanner }.is_poisoned()); -+ -+ unsafe { -+ lance_scanner_close(scanner); -+ lance_dataset_close(dataset); -+ } -+ } -+ -+ #[test] -+ fn raw_waker_clones_share_one_shot_gate() { -+ static WAKES: AtomicUsize = AtomicUsize::new(0); -+ unsafe extern "C" fn count_wake(_ctx: *mut c_void) { -+ WAKES.fetch_add(1, Ordering::SeqCst); -+ } -+ -+ WAKES.store(0, Ordering::SeqCst); -+ let registry = PollWakerRegistry::default(); -+ let waker = -+ unsafe { Waker::from_raw(make_raw_waker(®istry, count_wake, ptr::null_mut())) }; -+ let cloned = waker.clone(); -+ waker.wake_by_ref(); -+ cloned.wake_by_ref(); -+ drop(cloned); -+ drop(waker); -+ assert_eq!(WAKES.load(Ordering::SeqCst), 1); -+ } -+ -+ #[test] -+ fn scanner_close_cancels_a_retained_poll_waker() { -+ let (_tmp, uri) = create_test_dataset(); -+ let (dataset, scanner) = open_dataset_and_scanner(&uri); -+ let calls = Box::into_raw(Box::new(AtomicUsize::new(0))); -+ -+ unsafe extern "C" fn count_wake(ctx: *mut c_void) { -+ let calls = unsafe { &*(ctx.cast::()) }; -+ calls.fetch_add(1, Ordering::SeqCst); -+ } -+ -+ // Model a future retaining the RawWaker clone returned from a PENDING -+ // poll. Closing the scanner is the documented retirement boundary, so -+ // waking that retained clone afterwards must not touch callback_ctx. -+ let waker = unsafe { -+ Waker::from_raw(make_raw_waker( -+ &(*scanner).poll_wakers, -+ count_wake, -+ calls.cast(), -+ )) -+ }; -+ unsafe { lance_scanner_close(scanner) }; -+ waker.wake(); -+ -+ let calls = unsafe { Box::from_raw(calls) }; -+ assert_eq!( -+ calls.load(Ordering::SeqCst), -+ 0, -+ "a retained RawWaker invoked callback_ctx after scanner close" -+ ); -+ unsafe { lance_dataset_close(dataset) }; -+ } -+ -+ struct BlockingWakeProbe { -+ calls: AtomicUsize, -+ entered: Arc, -+ release: Arc, -+ } -+ -+ unsafe extern "C" fn blocking_waker(ctx: *mut c_void) { -+ let probe = unsafe { &*(ctx.cast::()) }; -+ probe.calls.fetch_add(1, Ordering::SeqCst); -+ probe.entered.wait(); -+ probe.release.wait(); -+ } -+ -+ #[test] -+ fn scanner_close_waits_for_an_active_poll_waker() { -+ let (_tmp, uri) = create_test_dataset(); -+ let (dataset, scanner) = open_dataset_and_scanner(&uri); -+ let entered = Arc::new(Barrier::new(2)); -+ let release = Arc::new(Barrier::new(2)); -+ let probe = Box::into_raw(Box::new(BlockingWakeProbe { -+ calls: AtomicUsize::new(0), -+ entered: Arc::clone(&entered), -+ release: Arc::clone(&release), -+ })); -+ let waker = unsafe { -+ Waker::from_raw(make_raw_waker( -+ &(*scanner).poll_wakers, -+ blocking_waker, -+ probe.cast(), -+ )) -+ }; -+ -+ let wake_thread = std::thread::spawn(move || waker.wake()); -+ entered.wait(); -+ -+ let close_started = Arc::new(Barrier::new(2)); -+ let close_started_in_thread = Arc::clone(&close_started); -+ let (closed_tx, closed_rx) = mpsc::channel(); -+ let scanner_address = scanner as usize; -+ let close_thread = std::thread::spawn(move || { -+ close_started_in_thread.wait(); -+ unsafe { lance_scanner_close(scanner_address as *mut LanceScanner) }; -+ closed_tx.send(()).unwrap(); -+ }); -+ close_started.wait(); -+ -+ let closed_while_callback_was_active = -+ closed_rx.recv_timeout(Duration::from_millis(500)).is_ok(); -+ release.wait(); -+ wake_thread.join().unwrap(); -+ close_thread.join().unwrap(); -+ -+ let probe = unsafe { Box::from_raw(probe) }; -+ assert_eq!(probe.calls.load(Ordering::SeqCst), 1); -+ assert!( -+ !closed_while_callback_was_active, -+ "scanner close returned before an active poll waker callback completed" -+ ); -+ unsafe { lance_dataset_close(dataset) }; -+ } -+ -+ fn panicking_setter_body() -> Result { -+ panic!("simulated panic in scanner setter") -+ } -+ -+ unsafe fn panicking_scanner_setter(scanner: *mut LanceScanner) -> i32 { -+ scanner_poison_check!(scanner, -1); -+ scanner_ffi_try!(scanner, panicking_setter_body()) -+ } -+ -+ #[test] -+ fn scanner_setter_panic_poisons_handle() { -+ let (_tmp, uri) = create_test_dataset(); -+ let (dataset, scanner) = open_dataset_and_scanner(&uri); -+ -+ let rc = unsafe { panicking_scanner_setter(scanner) }; -+ assert_eq!(rc, -1); -+ assert!(unsafe { &*scanner }.is_poisoned()); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::Panic); -+ let msg_ptr = lance_last_error_message(); -+ assert!(!msg_ptr.is_null()); -+ let msg = unsafe { CStr::from_ptr(msg_ptr) } -+ .to_string_lossy() -+ .into_owned(); -+ unsafe { crate::error::lance_free_string(msg_ptr) }; -+ assert!( -+ msg.contains("simulated panic in scanner setter"), -+ "got: {msg}" -+ ); -+ -+ // The original panic message is reported once; later calls use the -+ // stable poison error and never touch scanner state again. -+ let rc = unsafe { lance_scanner_set_limit(scanner, 10) }; -+ assert_eq!(rc, -1); -+ assert_poison_error_pending(); -+ -+ unsafe { -+ lance_scanner_close(scanner); -+ lance_dataset_close(dataset); -+ } -+ } -+ - #[test] - fn poisoned_scanner_rejects_setters_with_panic_code() { - let (_tmp, uri) = create_test_dataset(); -@@ -1745,12 +2419,17 @@ mod tests { - let (dataset, scanner) = open_dataset_and_scanner(&uri); - poison(scanner); - -- let mut batch: *mut LanceBatch = ptr::null_mut(); -+ let mut batch = std::ptr::NonNull::::dangling().as_ptr(); - let rc = unsafe { lance_scanner_next(scanner, &mut batch) }; - assert_eq!(rc, -1); - assert!(batch.is_null(), "error path must leave *out NULL"); - assert_poison_error_pending(); - -+ // Poison has precedence over validation of secondary arguments. -+ let rc = unsafe { lance_scanner_next(scanner, ptr::null_mut()) }; -+ assert_eq!(rc, -1); -+ assert_poison_error_pending(); -+ - unsafe { - lance_scanner_close(scanner); - lance_dataset_close(dataset); -@@ -1763,13 +2442,20 @@ mod tests { - let (dataset, scanner) = open_dataset_and_scanner(&uri); - poison(scanner); - -- let mut batch: *mut LanceBatch = ptr::null_mut(); -- let status = -- unsafe { lance_scanner_poll_next(scanner, noop_waker, ptr::null_mut(), &mut batch) }; -+ let mut batch = std::ptr::NonNull::::dangling().as_ptr(); -+ let status = unsafe { -+ lance_scanner_poll_next(scanner, Some(noop_waker), ptr::null_mut(), &mut batch) -+ }; - assert_eq!(status, LancePollStatus::Error); - assert!(batch.is_null(), "error path must leave *out NULL"); - assert_poison_error_pending(); - -+ let status = unsafe { -+ lance_scanner_poll_next(scanner, Some(noop_waker), ptr::null_mut(), ptr::null_mut()) -+ }; -+ assert_eq!(status, LancePollStatus::Error); -+ assert_poison_error_pending(); -+ - unsafe { - lance_scanner_close(scanner); - lance_dataset_close(dataset); -@@ -1809,7 +2495,7 @@ mod tests { - let (dataset, scanner) = open_dataset_and_scanner(&uri); - poison(scanner); - -- unsafe { lance_scanner_scan_async(scanner, record_status, ptr::null_mut()) }; -+ unsafe { lance_scanner_scan_async(scanner, Some(record_status), ptr::null_mut()) }; - // The poison error is also visible on the calling thread. - assert_poison_error_pending(); - -diff --git a/src/stream_guard.rs b/src/stream_guard.rs -index f4f418e..d785082 100644 ---- a/src/stream_guard.rs -+++ b/src/stream_guard.rs -@@ -43,6 +43,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - -+use arrow::ffi::FFI_ArrowSchema; -+use arrow::ffi_stream::FFI_ArrowArrayStream; - use arrow::record_batch::RecordBatchReader; - use arrow_array::RecordBatch; - use arrow_schema::{ArrowError, SchemaRef}; -@@ -50,6 +52,44 @@ use futures::{Stream, StreamExt}; - - use crate::error::{panic_payload_message, swallow_unwind}; - -+/// An owned, NUL-free error whose `Display` implementation cannot call back -+/// into an arbitrary external error source. Arrow formats this value from -+/// inside its non-unwinding `get_next` callback. -+#[derive(Debug)] -+struct FfiSafeStreamError(String); -+ -+impl std::fmt::Display for FfiSafeStreamError { -+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -+ f.write_str(&self.0) -+ } -+} -+ -+impl std::error::Error for FfiSafeStreamError {} -+ -+fn ffi_safe_stream_error(message: String) -> ArrowError { -+ ArrowError::ExternalError(Box::new(FfiSafeStreamError(message.replace('\0', "\\0")))) -+} -+ -+/// Exercise arrow-rs's exact schema conversion before its non-unwinding -+/// `get_schema` callback is exposed to C. -+fn preflight_schema(schema: &SchemaRef) -> std::result::Result<(), ArrowError> { -+ match catch_unwind(AssertUnwindSafe(|| { -+ let ffi_schema = FFI_ArrowSchema::try_from(schema.as_ref()).map_err(|err| { -+ // Detach the error under the guard for the same reason `next` -+ // does: this value may ultimately be formatted by an FFI caller. -+ ffi_safe_stream_error(err.to_string()) -+ })?; -+ drop(ffi_schema); -+ Ok(()) -+ })) { -+ Ok(result) => result, -+ Err(payload) => Err(ffi_safe_stream_error(format!( -+ "panic exporting Arrow schema: {}", -+ panic_payload_message(&*payload) -+ ))), -+ } -+} -+ - /// A [`RecordBatchReader`] that owns the exported Lance stream, drives it - /// with a Tokio runtime handle, and contains panics at both C-reachable - /// edges (`next` and `drop`) — see the module docs for why the guard lives -@@ -74,12 +114,26 @@ impl GuardedReader { - /// Wrap `inner`, driving it with `handle` and wiring the shared - /// `scanner_poison` flag that a caught panic sets (from - /// `LanceScanner::poison_flag()` at the export sites). -+ /// -+ /// # Panics -+ /// -+ /// Panics if `schema` cannot be converted to the Arrow C Data Interface. -+ /// Production callers construct this reader inside their outer FFI panic -+ /// guard, before arrow-rs's non-unwinding `get_schema` callback is exposed. - pub fn new( - inner: S, - schema: SchemaRef, - handle: tokio::runtime::Handle, - scanner_poison: Arc, - ) -> Self { -+ // arrow-rs converts this schema later from inside its non-unwinding -+ // `get_schema` callback. Perform the exact conversion once while the -+ // scanner export's outer catch_unwind is still active, so a malformed -+ // schema (for example, a field name containing NUL) cannot first -+ // panic after control has crossed into that callback. -+ preflight_schema(&schema) -+ .unwrap_or_else(|err| panic!("Arrow schema cannot be exported: {err}")); -+ - Self { - inner: Some(inner), - schema, -@@ -115,19 +169,27 @@ where - // stream's `poll_next` lands here, one frame below arrow-rs's - // `extern "C"` callback, so neither can unwind across the FFI - // boundary. -- let polled = catch_unwind(AssertUnwindSafe(|| handle.block_on(inner.next()))); -+ let polled = catch_unwind(AssertUnwindSafe(|| { -+ match handle.block_on(inner.next()) { -+ Some(Ok(batch)) => Some(Ok(batch)), -+ Some(Err(err)) => { -+ // Format and detach the arbitrary Lance error while still -+ // inside the guard. arrow-rs later calls Display and -+ // CString::new from a non-unwinding callback, so neither a -+ // panicking source nor an embedded NUL may reach it. -+ Some(Err(ffi_safe_stream_error(err.to_string()))) -+ } -+ None => None, -+ } -+ })); - match polled { -- Ok(Some(Ok(batch))) => Some(Ok(batch)), -- Ok(Some(Err(err))) => Some(Err(ArrowError::ExternalError(Box::new(err)))), -- Ok(None) => None, -+ Ok(item) => item, - Err(payload) => { - *poisoned = true; - scanner_poison.store(true, Ordering::SeqCst); -- Some(Err(ArrowError::ExternalError(Box::new( -- lance_core::Error::internal(format!( -- "panic in stream: {}", -- panic_payload_message(&*payload) -- )), -+ Some(Err(ffi_safe_stream_error(format!( -+ "panic in stream: {}", -+ panic_payload_message(&*payload) - )))) - } - } -@@ -158,11 +220,101 @@ impl Drop for GuardedReader { - } - } - -+/// A panic-safe owner for an already-materialized [`RecordBatchReader`]. -+/// -+/// Dataset `take` operations use readers whose batches are already in memory, -+/// so no Tokio handle is needed. Arrow still invokes `schema`, `next`, and -+/// `drop` later from non-unwinding C callbacks, however, which requires the -+/// same error-detachment and cleanup containment as [`GuardedReader`]. -+struct GuardedRecordBatchReader { -+ inner: Option, -+ schema: SchemaRef, -+ poisoned: bool, -+} -+ -+impl Iterator for GuardedRecordBatchReader -+where -+ R: RecordBatchReader, -+{ -+ type Item = std::result::Result; -+ -+ fn next(&mut self) -> Option { -+ if self.poisoned { -+ return None; -+ } -+ -+ let inner = self.inner.as_mut()?; -+ let next = catch_unwind(AssertUnwindSafe(|| match inner.next() { -+ Some(Ok(batch)) => Some(Ok(batch)), -+ Some(Err(err)) => Some(Err(ffi_safe_stream_error(err.to_string()))), -+ None => None, -+ })); -+ -+ match next { -+ Ok(item) => item, -+ Err(payload) => { -+ self.poisoned = true; -+ Some(Err(ffi_safe_stream_error(format!( -+ "panic in record batch reader: {}", -+ panic_payload_message(&*payload) -+ )))) -+ } -+ } -+ } -+} -+ -+impl RecordBatchReader for GuardedRecordBatchReader -+where -+ R: RecordBatchReader + Send, -+{ -+ fn schema(&self) -> SchemaRef { -+ Arc::clone(&self.schema) -+ } -+} -+ -+impl Drop for GuardedRecordBatchReader { -+ fn drop(&mut self) { -+ let Some(inner) = self.inner.take() else { -+ return; -+ }; -+ swallow_unwind( -+ "GuardedRecordBatchReader::drop (ArrowArrayStream release)", -+ || drop(inner), -+ ); -+ } -+} -+ -+/// Export an already-materialized reader through panic-safe Arrow C stream -+/// callbacks. -+/// -+/// The schema is converted once before the callback table is returned. This -+/// turns deterministic schema conversion failures into an ordinary export -+/// failure (or lets the caller's outer FFI guard catch an arrow-rs conversion -+/// panic) instead of deferring them to `get_schema`. -+pub(crate) fn guarded_ffi_stream_from_reader( -+ reader: R, -+) -> std::result::Result -+where -+ R: RecordBatchReader + Send + 'static, -+{ -+ let schema = reader.schema(); -+ preflight_schema(&schema)?; -+ let reader = GuardedRecordBatchReader { -+ inner: Some(reader), -+ schema, -+ poisoned: false, -+ }; -+ Ok(FFI_ArrowArrayStream::new(Box::new(reader))) -+} -+ - #[cfg(test)] - mod tests { - use super::*; -+ use arrow::ffi::FFI_ArrowArray; -+ use arrow::ffi_stream::FFI_ArrowArrayStream; - use arrow_array::Int32Array; - use arrow_schema::{DataType, Field, Schema}; -+ use std::ffi::CStr; - use std::pin::Pin; - use std::task::{Context, Poll}; - -@@ -187,6 +339,17 @@ mod tests { - message: &'static str, - } - -+ #[derive(Debug)] -+ struct PanickingDisplay; -+ -+ impl std::fmt::Display for PanickingDisplay { -+ fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -+ panic!("simulated panic while formatting a reader error") -+ } -+ } -+ -+ impl std::error::Error for PanickingDisplay {} -+ - impl Stream for PanicOnSecondPoll { - type Item = lance_core::Result; - -@@ -212,6 +375,76 @@ mod tests { - (rt, reader) - } - -+ fn guarded_export( -+ stream: S, -+ schema: SchemaRef, -+ scanner_poison: Arc, -+ ) -> (tokio::runtime::Runtime, FFI_ArrowArrayStream) -+ where -+ S: Stream> + Unpin + Send + 'static, -+ { -+ let rt = tokio::runtime::Runtime::new().unwrap(); -+ let reader = GuardedReader::new(stream, schema, rt.handle().clone(), scanner_poison); -+ (rt, FFI_ArrowArrayStream::new(Box::new(reader))) -+ } -+ -+ unsafe fn c_get_next(stream: *mut FFI_ArrowArrayStream, array: *mut FFI_ArrowArray) -> i32 { -+ let get_next = unsafe { (*stream).get_next }.expect("get_next callback is NULL"); -+ unsafe { get_next(stream, array) } -+ } -+ -+ unsafe fn c_get_schema(stream: *mut FFI_ArrowArrayStream, schema: *mut FFI_ArrowSchema) -> i32 { -+ let get_schema = unsafe { (*stream).get_schema }.expect("get_schema callback is NULL"); -+ unsafe { get_schema(stream, schema) } -+ } -+ -+ unsafe fn c_get_last_error(stream: *mut FFI_ArrowArrayStream) -> Option { -+ let get_last_error = -+ unsafe { (*stream).get_last_error }.expect("get_last_error callback is NULL"); -+ let message = unsafe { get_last_error(stream) }; -+ if message.is_null() { -+ None -+ } else { -+ Some( -+ unsafe { CStr::from_ptr(message) } -+ .to_string_lossy() -+ .into_owned(), -+ ) -+ } -+ } -+ -+ fn run_child(test_name: &str, environment_variable: &str) -> std::process::Output { -+ let exact_name = format!("stream_guard::tests::{test_name}"); -+ std::process::Command::new(std::env::current_exe().unwrap()) -+ .args([&exact_name, "--exact", "--nocapture", "--test-threads=1"]) -+ .env(environment_variable, "1") -+ .output() -+ .unwrap() -+ } -+ -+ fn assert_child_succeeds(test_name: &str, environment_variable: &str) -> String { -+ let output = run_child(test_name, environment_variable); -+ let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); -+ assert!( -+ output.status.success(), -+ "guarded child must exit cleanly, got status {:?}\nstderr:\n{stderr}", -+ output.status -+ ); -+ stderr -+ } -+ -+ fn raw_error_then_eos(stream: &mut FFI_ArrowArrayStream) -> String { -+ let mut array = FFI_ArrowArray::empty(); -+ let status = unsafe { c_get_next(stream, &mut array) }; -+ assert_ne!(status, 0, "expected an Arrow C stream error"); -+ let message = unsafe { c_get_last_error(stream) }.expect("get_last_error returned NULL"); -+ -+ let mut eos = FFI_ArrowArray::empty(); -+ assert_eq!(unsafe { c_get_next(stream, &mut eos) }, 0); -+ assert!(eos.release.is_none(), "error must be followed by EOS"); -+ message -+ } -+ - #[test] - fn panic_yields_one_error_then_fuses_and_flips_flag() { - let scanner_poison = Arc::new(AtomicBool::new(false)); -@@ -363,6 +596,48 @@ mod tests { - } - } - -+ struct PanicOnReaderNext { -+ schema: SchemaRef, -+ } -+ -+ impl Iterator for PanicOnReaderNext { -+ type Item = std::result::Result; -+ -+ fn next(&mut self) -> Option { -+ panic!("simulated panic in materialized reader next"); -+ } -+ } -+ -+ impl RecordBatchReader for PanicOnReaderNext { -+ fn schema(&self) -> SchemaRef { -+ Arc::clone(&self.schema) -+ } -+ } -+ -+ struct PanicOnReaderDrop { -+ schema: SchemaRef, -+ } -+ -+ impl Iterator for PanicOnReaderDrop { -+ type Item = std::result::Result; -+ -+ fn next(&mut self) -> Option { -+ None -+ } -+ } -+ -+ impl RecordBatchReader for PanicOnReaderDrop { -+ fn schema(&self) -> SchemaRef { -+ Arc::clone(&self.schema) -+ } -+ } -+ -+ impl Drop for PanicOnReaderDrop { -+ fn drop(&mut self) { -+ panic!("simulated panic in materialized reader drop"); -+ } -+ } -+ - /// Regression for the review finding that the release path was unguarded: - /// arrow-rs's `release_stream` drops this reader inside its `extern "C"` - /// callback, so a cleanup panic must be contained here (best-effort: -@@ -381,4 +656,311 @@ mod tests { - "cleanup panic is best-effort and must not poison the handle" - ); - } -+ -+ #[test] -+ fn raw_stream_get_next_contains_poll_panic() { -+ const CHILD: &str = "LANCE_C_CHILD_STREAM_POLL_PANIC"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = assert_child_succeeds("raw_stream_get_next_contains_poll_panic", CHILD); -+ assert!(stderr.contains("simulated raw poll panic")); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let (_runtime, mut stream) = guarded_export( -+ PanicOnSecondPoll { -+ yielded: false, -+ message: "simulated raw poll panic", -+ }, -+ test_schema(), -+ Arc::clone(&scanner_poison), -+ ); -+ -+ let mut first = FFI_ArrowArray::empty(); -+ assert_eq!(unsafe { c_get_next(&mut stream, &mut first) }, 0); -+ assert!(first.release.is_some()); -+ unsafe { first.release.unwrap()(&mut first) }; -+ -+ let message = raw_error_then_eos(&mut stream); -+ assert!( -+ message.contains("simulated raw poll panic"), -+ "got: {message}" -+ ); -+ assert!(scanner_poison.load(Ordering::SeqCst)); -+ } -+ -+ #[test] -+ fn raw_stream_get_next_sanitizes_regular_error() { -+ const CHILD: &str = "LANCE_C_CHILD_STREAM_NUL_ERROR"; -+ if std::env::var(CHILD).is_err() { -+ assert_child_succeeds("raw_stream_get_next_sanitizes_regular_error", CHILD); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( -+ "ordinary error with a NUL: bo\0om".into(), -+ ))]); -+ let (_runtime, mut stream) = -+ guarded_export(stream, test_schema(), Arc::clone(&scanner_poison)); -+ -+ let message = raw_error_then_eos(&mut stream); -+ assert!(message.contains("bo\\0om"), "got: {message:?}"); -+ assert!(!message.contains('\0')); -+ assert!(!scanner_poison.load(Ordering::SeqCst)); -+ } -+ -+ #[test] -+ fn raw_stream_get_next_contains_error_display_panic() { -+ const CHILD: &str = "LANCE_C_CHILD_STREAM_DISPLAY_PANIC"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = -+ assert_child_succeeds("raw_stream_get_next_contains_error_display_panic", CHILD); -+ assert!(stderr.contains("simulated panic while formatting a reader error")); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( -+ Box::new(PanickingDisplay), -+ ))]); -+ let (_runtime, mut stream) = -+ guarded_export(stream, test_schema(), Arc::clone(&scanner_poison)); -+ -+ let message = raw_error_then_eos(&mut stream); -+ assert!( -+ message.contains("simulated panic while formatting a reader error"), -+ "got: {message}" -+ ); -+ assert!(scanner_poison.load(Ordering::SeqCst)); -+ } -+ -+ #[test] -+ fn stream_schema_is_rejected_before_raw_get_schema_is_exposed() { -+ const CHILD: &str = "LANCE_C_CHILD_STREAM_NUL_SCHEMA"; -+ if std::env::var(CHILD).is_err() { -+ assert_child_succeeds( -+ "stream_schema_is_rejected_before_raw_get_schema_is_exposed", -+ CHILD, -+ ); -+ return; -+ } -+ -+ let schema = Arc::new(Schema::new(vec![Field::new( -+ "field\0name", -+ DataType::Int32, -+ false, -+ )])); -+ let runtime = tokio::runtime::Runtime::new().unwrap(); -+ let callback_was_exposed = std::cell::Cell::new(false); -+ let outcome = catch_unwind(AssertUnwindSafe(|| { -+ let reader = GuardedReader::new( -+ futures::stream::empty::>(), -+ schema, -+ runtime.handle().clone(), -+ Arc::new(AtomicBool::new(false)), -+ ); -+ callback_was_exposed.set(true); -+ let mut stream = FFI_ArrowArrayStream::new(Box::new(reader)); -+ let mut ffi_schema = FFI_ArrowSchema::empty(); -+ unsafe { c_get_schema(&mut stream, &mut ffi_schema) } -+ })); -+ -+ assert!(outcome.is_err(), "invalid schema must fail during export"); -+ assert!( -+ !callback_was_exposed.get(), -+ "invalid schema reached raw get_schema" -+ ); -+ } -+ -+ #[test] -+ fn raw_stream_get_next_inside_tokio_runtime_is_contained() { -+ const CHILD: &str = "LANCE_C_CHILD_STREAM_NESTED_RUNTIME"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = assert_child_succeeds( -+ "raw_stream_get_next_inside_tokio_runtime_is_contained", -+ CHILD, -+ ); -+ assert!(stderr.contains("Cannot start a runtime from within a runtime")); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let (runtime, mut stream) = guarded_export( -+ futures::stream::iter(vec![Ok(test_batch())]), -+ test_schema(), -+ Arc::clone(&scanner_poison), -+ ); -+ let mut array = FFI_ArrowArray::empty(); -+ let status = runtime.block_on(async { unsafe { c_get_next(&mut stream, &mut array) } }); -+ assert_ne!(status, 0); -+ let message = unsafe { c_get_last_error(&mut stream) }.unwrap(); -+ assert!(message.contains("runtime"), "got: {message}"); -+ assert!(scanner_poison.load(Ordering::SeqCst)); -+ -+ let mut eos = FFI_ArrowArray::empty(); -+ assert_eq!(unsafe { c_get_next(&mut stream, &mut eos) }, 0); -+ assert!(eos.release.is_none()); -+ } -+ -+ #[test] -+ fn raw_stream_release_contains_drop_panic() { -+ const CHILD: &str = "LANCE_C_CHILD_STREAM_DROP_PANIC"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = assert_child_succeeds("raw_stream_release_contains_drop_panic", CHILD); -+ assert!(stderr.contains("simulated drop bug in stream cleanup")); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let (_runtime, mut stream) = -+ guarded_export(PanicOnDrop, test_schema(), Arc::clone(&scanner_poison)); -+ let release = stream.release.expect("release callback is NULL"); -+ unsafe { release(&mut stream) }; -+ assert!(stream.release.is_none()); -+ assert!(!scanner_poison.load(Ordering::SeqCst)); -+ } -+ -+ #[test] -+ fn guarded_in_memory_export_supports_raw_arrow_callbacks() { -+ let reader = -+ arrow::record_batch::RecordBatchIterator::new(vec![Ok(test_batch())], test_schema()); -+ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); -+ -+ let mut schema = FFI_ArrowSchema::empty(); -+ let get_schema = stream.get_schema.expect("get_schema callback is NULL"); -+ assert_eq!(unsafe { get_schema(&mut stream, &mut schema) }, 0); -+ assert!(schema.release.is_some()); -+ unsafe { schema.release.unwrap()(&mut schema) }; -+ -+ let get_next = stream.get_next.expect("get_next callback is NULL"); -+ let mut array = FFI_ArrowArray::empty(); -+ assert_eq!(unsafe { get_next(&mut stream, &mut array) }, 0); -+ assert!(array.release.is_some()); -+ unsafe { array.release.unwrap()(&mut array) }; -+ -+ let mut eos = FFI_ArrowArray::empty(); -+ assert_eq!(unsafe { get_next(&mut stream, &mut eos) }, 0); -+ assert!(eos.release.is_none()); -+ -+ let release = stream.release.expect("release callback is NULL"); -+ unsafe { release(&mut stream) }; -+ assert!(stream.release.is_none()); -+ } -+ -+ #[test] -+ fn guarded_in_memory_get_next_sanitizes_nul_error() { -+ const CHILD: &str = "LANCE_C_CHILD_READER_NUL_ERROR"; -+ if std::env::var(CHILD).is_err() { -+ assert_child_succeeds("guarded_in_memory_get_next_sanitizes_nul_error", CHILD); -+ return; -+ } -+ -+ let reader = arrow::record_batch::RecordBatchIterator::new( -+ vec![Err(ArrowError::ComputeError( -+ "ordinary reader error: bo\0om".into(), -+ ))], -+ test_schema(), -+ ); -+ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); -+ let message = raw_error_then_eos(&mut stream); -+ assert!(message.contains("bo\\0om"), "got: {message:?}"); -+ assert!(!message.contains('\0')); -+ } -+ -+ #[test] -+ fn guarded_in_memory_get_next_contains_error_display_panic() { -+ const CHILD: &str = "LANCE_C_CHILD_READER_DISPLAY_PANIC"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = assert_child_succeeds( -+ "guarded_in_memory_get_next_contains_error_display_panic", -+ CHILD, -+ ); -+ assert!(stderr.contains("simulated panic while formatting a reader error")); -+ return; -+ } -+ -+ let reader = arrow::record_batch::RecordBatchIterator::new( -+ vec![Err(ArrowError::ExternalError(Box::new(PanickingDisplay)))], -+ test_schema(), -+ ); -+ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); -+ let message = raw_error_then_eos(&mut stream); -+ assert!( -+ message.contains("simulated panic while formatting a reader error"), -+ "got: {message}" -+ ); -+ } -+ -+ #[test] -+ fn guarded_in_memory_get_next_contains_reader_panic() { -+ const CHILD: &str = "LANCE_C_CHILD_READER_NEXT_PANIC"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = -+ assert_child_succeeds("guarded_in_memory_get_next_contains_reader_panic", CHILD); -+ assert!(stderr.contains("simulated panic in materialized reader next")); -+ return; -+ } -+ -+ let reader = PanicOnReaderNext { -+ schema: test_schema(), -+ }; -+ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); -+ let message = raw_error_then_eos(&mut stream); -+ assert!( -+ message.contains("simulated panic in materialized reader next"), -+ "got: {message}" -+ ); -+ } -+ -+ #[test] -+ fn guarded_in_memory_rejects_nul_schema_before_callback_exposure() { -+ const CHILD: &str = "LANCE_C_CHILD_READER_NUL_SCHEMA"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = assert_child_succeeds( -+ "guarded_in_memory_rejects_nul_schema_before_callback_exposure", -+ CHILD, -+ ); -+ assert!(stderr.contains("NulError")); -+ return; -+ } -+ -+ let schema = Arc::new(Schema::new(vec![Field::new( -+ "field\0name", -+ DataType::Int32, -+ false, -+ )])); -+ let reader = arrow::record_batch::RecordBatchIterator::new( -+ Vec::>::new(), -+ schema, -+ ); -+ -+ let result = guarded_ffi_stream_from_reader(reader); -+ let error = result.expect_err("invalid schema must fail before export"); -+ assert!( -+ error.to_string().contains("panic exporting Arrow schema"), -+ "got: {error}" -+ ); -+ } -+ -+ #[test] -+ fn guarded_in_memory_release_contains_reader_drop_panic() { -+ const CHILD: &str = "LANCE_C_CHILD_READER_DROP_PANIC"; -+ if std::env::var(CHILD).is_err() { -+ let stderr = assert_child_succeeds( -+ "guarded_in_memory_release_contains_reader_drop_panic", -+ CHILD, -+ ); -+ assert!(stderr.contains("simulated panic in materialized reader drop")); -+ return; -+ } -+ -+ let reader = PanicOnReaderDrop { -+ schema: test_schema(), -+ }; -+ let mut stream = guarded_ffi_stream_from_reader(reader).unwrap(); -+ let release = stream.release.expect("release callback is NULL"); -+ unsafe { release(&mut stream) }; -+ assert!(stream.release.is_none()); -+ } - } -diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index daa7425..f83b004 100644 ---- a/tests/c_api_test.rs -+++ b/tests/c_api_test.rs -@@ -993,7 +993,7 @@ fn test_scanner_scan_async() { - unsafe { - lance_scanner_scan_async( - scanner, -- on_complete, -+ Some(on_complete), - Arc::as_ptr(&pair_clone) as *mut std::ffi::c_void, - ); - lance_scanner_close(scanner); -@@ -1015,10 +1015,25 @@ fn test_scanner_scan_async() { - assert_eq!(total_rows, 5); - assert_eq!(captured.calls.load(AtomicOrdering::SeqCst), 1); - assert!(!captured.invalid_statistics.load(AtomicOrdering::SeqCst)); -+ unsafe { -+ lance_scanner_async_stream_free(result.stream_ptr.cast::()); -+ } - - unsafe { lance_dataset_close(ds) }; - } - -+#[test] -+fn test_scanner_async_stream_free_releases_stream_and_accepts_null() { -+ let (stream, drop_count) = make_counted_column_stream("value", vec![1]); -+ let stream = Box::into_raw(Box::new(stream)); -+ -+ unsafe { lance_scanner_async_stream_free(stream) }; -+ assert_eq!(drop_count.load(AtomicOrdering::SeqCst), 1); -+ -+ // Match the other close/free APIs: NULL is a no-op. -+ unsafe { lance_scanner_async_stream_free(ptr::null_mut()) }; -+} -+ - // =========================================================================== - // Additional tests - // =========================================================================== -@@ -1594,7 +1609,7 @@ fn test_async_scan_with_filter() { - unsafe { - lance_scanner_scan_async( - scanner, -- on_complete, -+ Some(on_complete), - Arc::as_ptr(&pair_clone) as *mut std::ffi::c_void, - ); - } -@@ -1609,6 +1624,9 @@ fn test_async_scan_with_filter() { - let ffi_stream = unsafe { &mut *(result.stream_ptr as *mut FFI_ArrowArrayStream) }; - let reader = unsafe { ArrowArrayStreamReader::from_raw(ffi_stream) }.unwrap(); - assert_eq!(reader.map(|r| r.unwrap().num_rows()).sum::(), 2); -+ unsafe { -+ lance_scanner_async_stream_free(result.stream_ptr.cast::()); -+ } - - unsafe { lance_scanner_close(scanner) }; - unsafe { lance_dataset_close(ds) }; -@@ -1641,7 +1659,7 @@ fn test_poll_next_basic() { - loop { - let mut batch: *mut LanceBatch = ptr::null_mut(); - let status = unsafe { -- lance_scanner_poll_next(scanner, test_waker, ptr::null_mut(), &mut batch) -+ lance_scanner_poll_next(scanner, Some(test_waker), ptr::null_mut(), &mut batch) - }; - match status { - LancePollStatus::Ready => { -@@ -3042,6 +3060,45 @@ fn test_index_segment_builder_owns_snapshot_and_is_single_use() { - } - } - -+#[test] -+fn test_index_segment_metadata_accessors_reject_null_handles() { -+ assert!(unsafe { lance_index_segment_metadata_name(ptr::null()) }.is_null()); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ assert_eq!( -+ unsafe { lance_index_segment_metadata_dataset_version(ptr::null()) }, -+ 0 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ assert_eq!( -+ unsafe { lance_index_segment_metadata_index_version(ptr::null()) }, -+ -1 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ assert_eq!( -+ unsafe { lance_index_segment_metadata_index_type(ptr::null()) }, -+ -1 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ assert!(unsafe { lance_index_segment_metadata_index_details_type_url(ptr::null()) }.is_null()); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ assert_eq!( -+ unsafe { lance_index_segment_metadata_field_count(ptr::null()) }, -+ 0 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ -+ assert_eq!( -+ unsafe { lance_index_segment_metadata_fragment_count(ptr::null()) }, -+ 0 -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+} -+ - #[test] - fn test_index_segment_metadata_parse_rejects_malformed_and_dangerous_input() { - use prost::Message; -@@ -5543,6 +5600,509 @@ fn test_fts_fuzzy() { - unsafe { lance_dataset_close(ds) }; - } - -+fn collect_context_fts_scores( -+ dataset: *const LanceDataset, -+ context: *const LanceFtsQueryContext, -+ segment_uuids: Option<&[[u8; 16]]>, -+) -> std::collections::HashMap { -+ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, -+ 0, -+ "{}", -+ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } -+ ); -+ if let Some(segment_uuids) = segment_uuids { -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_fts_index_segments( -+ scanner, -+ segment_uuids.as_ptr().cast::(), -+ segment_uuids.len(), -+ ) -+ }, -+ 0 -+ ); -+ } -+ -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0, -+ "{}", -+ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; -+ let mut scores = std::collections::HashMap::new(); -+ for batch in reader { -+ let batch = batch.unwrap(); -+ let ids = batch -+ .column_by_name("id") -+ .unwrap() -+ .as_any() -+ .downcast_ref::() -+ .unwrap(); -+ let batch_scores = batch -+ .column_by_name("_score") -+ .unwrap() -+ .as_any() -+ .downcast_ref::() -+ .unwrap(); -+ for row in 0..batch.num_rows() { -+ assert!( -+ scores -+ .insert(ids.value(row), batch_scores.value(row)) -+ .is_none() -+ ); -+ } -+ } -+ unsafe { lance_scanner_close(scanner) }; -+ scores -+} -+ -+fn load_fts_segment_uuids(uri: &str, column: &str) -> Vec<[u8; 16]> { -+ use lance::index::DatasetIndexExt; -+ use lance_index::IndexCriteria; -+ -+ lance_c::runtime::block_on(async { -+ let dataset = Dataset::open(uri).await.unwrap(); -+ let logical_index = dataset -+ .load_scalar_index(IndexCriteria::default().for_column(column).supports_fts()) -+ .await -+ .unwrap() -+ .unwrap(); -+ dataset -+ .load_indices_by_name(&logical_index.name) -+ .await -+ .unwrap() -+ .into_iter() -+ .map(|segment| *segment.uuid.as_bytes()) -+ .collect() -+ }) -+} -+ -+#[test] -+fn test_prepared_fts_row_id_output_is_explicit() { -+ let (_tmp, uri) = create_test_dataset(); -+ let uri_c = c_str(&uri); -+ let column = c_str("name"); -+ let query = c_str("alice"); -+ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); -+ -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ assert_eq!( -+ unsafe { -+ lance_dataset_create_scalar_index( -+ dataset, -+ column.as_ptr(), -+ ptr::null(), -+ LanceScalarIndexType::Inverted as i32, -+ inverted_params.as_ptr(), -+ false, -+ ) -+ }, -+ 0 -+ ); -+ let context = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ }; -+ assert!(!context.is_null(), "{}", unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() -+ }); -+ -+ let id = c_str("id"); -+ let columns = [id.as_ptr(), ptr::null()]; -+ let scan_schema = |with_row_id: bool| { -+ let scanner = unsafe { lance_scanner_new(dataset, columns.as_ptr(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ if with_row_id { -+ assert_eq!(unsafe { lance_scanner_with_row_id(scanner, true) }, 0); -+ } -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, -+ 0 -+ ); -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0, -+ "{}", -+ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; -+ let schema = reader.schema(); -+ let rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert!(rows > 0); -+ unsafe { lance_scanner_close(scanner) }; -+ schema -+ }; -+ -+ let without_row_id = scan_schema(false); -+ assert_eq!(without_row_id.fields().len(), 2); -+ assert!(without_row_id.field_with_name("id").is_ok()); -+ assert!(without_row_id.field_with_name("_score").is_ok()); -+ assert!(without_row_id.field_with_name("_rowid").is_err()); -+ -+ let with_row_id = scan_schema(true); -+ assert_eq!(with_row_id.fields().len(), 3); -+ assert!(with_row_id.field_with_name("id").is_ok()); -+ assert!(with_row_id.field_with_name("_score").is_ok()); -+ assert!(with_row_id.field_with_name("_rowid").is_ok()); -+ -+ unsafe { lance_fts_query_context_close(context) }; -+ unsafe { lance_dataset_close(dataset) }; -+} -+ -+#[test] -+fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { -+ let (_tmp, uri) = create_test_dataset(); -+ let uri_c = c_str(&uri); -+ let column = c_str("name"); -+ let query = c_str("alice"); -+ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); -+ -+ let indexed_snapshot = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ assert_eq!( -+ unsafe { -+ lance_dataset_create_scalar_index( -+ indexed_snapshot, -+ column.as_ptr(), -+ ptr::null(), -+ LanceScalarIndexType::Inverted as i32, -+ inverted_params.as_ptr(), -+ false, -+ ) -+ }, -+ 0 -+ ); -+ unsafe { lance_dataset_close(indexed_snapshot) }; -+ -+ let schema = Arc::new(Schema::new(vec![ -+ Field::new("id", DataType::Int32, false), -+ Field::new("name", DataType::Utf8, true), -+ ])); -+ let batch = RecordBatch::try_new( -+ schema.clone(), -+ vec![ -+ Arc::new(Int32Array::from(vec![6, 7])), -+ Arc::new(StringArray::from(vec!["alice", "alice alice"])), -+ ], -+ ) -+ .unwrap(); -+ append_batch(&uri, schema, batch); -+ -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ let strict = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ }; -+ assert!(strict.is_null()); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ let message = unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()) -+ .to_string_lossy() -+ .into_owned() -+ }; -+ assert!(message.contains("unindexed fragments"), "{message}"); -+ -+ let context = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::IndexOnly as i32, -+ ) -+ }; -+ assert!(!context.is_null()); -+ let segment_uuids = load_fts_segment_uuids(&uri, "name"); -+ assert_eq!(segment_uuids.len(), 1); -+ -+ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, -+ 0 -+ ); -+ // Scanner retains an Arc; closing the public handle does not invalidate it. -+ unsafe { lance_fts_query_context_close(context) }; -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_fts_index_segments( -+ scanner, -+ segment_uuids.as_ptr().cast::(), -+ segment_uuids.len(), -+ ) -+ }, -+ 0 -+ ); -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0 -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; -+ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert_eq!( -+ total_rows, 1, -+ "INDEX_ONLY must exclude both matching rows in the unindexed fragment" -+ ); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(dataset) }; -+} -+ -+#[test] -+fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() { -+ use lance::index::DatasetIndexExt; -+ use lance_index::optimize::OptimizeOptions; -+ -+ let (_tmp, uri) = create_test_dataset(); -+ let uri_c = c_str(&uri); -+ let column = c_str("name"); -+ let query = c_str("alice"); -+ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); -+ -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ assert_eq!( -+ unsafe { -+ lance_dataset_create_scalar_index( -+ dataset, -+ column.as_ptr(), -+ ptr::null(), -+ LanceScalarIndexType::Inverted as i32, -+ inverted_params.as_ptr(), -+ false, -+ ) -+ }, -+ 0 -+ ); -+ unsafe { lance_dataset_close(dataset) }; -+ -+ let schema = Arc::new(Schema::new(vec![ -+ Field::new("id", DataType::Int32, false), -+ Field::new("name", DataType::Utf8, true), -+ ])); -+ append_batch( -+ &uri, -+ schema.clone(), -+ RecordBatch::try_new( -+ schema, -+ vec![ -+ Arc::new(Int32Array::from(vec![6, 7])), -+ Arc::new(StringArray::from(vec!["alice", "alice alice"])), -+ ], -+ ) -+ .unwrap(), -+ ); -+ lance_c::runtime::block_on(async { -+ let mut dataset = Dataset::open(&uri).await.unwrap(); -+ dataset -+ .optimize_indices(&OptimizeOptions::append()) -+ .await -+ .unwrap(); -+ }); -+ -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ let context = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ }; -+ assert!(!context.is_null(), "{}", unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() -+ }); -+ let segment_uuids = load_fts_segment_uuids(&uri, "name"); -+ assert_eq!(segment_uuids.len(), 2); -+ -+ let full_scores = collect_context_fts_scores(dataset, context, None); -+ assert_eq!(full_scores.len(), 3); -+ let mut split_scores = std::collections::HashMap::new(); -+ for segment_uuid in &segment_uuids { -+ for (id, score) in -+ collect_context_fts_scores(dataset, context, Some(std::slice::from_ref(segment_uuid))) -+ { -+ assert!(split_scores.insert(id, score).is_none()); -+ } -+ } -+ assert_eq!(split_scores.len(), full_scores.len()); -+ for (id, expected_score) in full_scores { -+ let actual_score = split_scores.get(&id).unwrap(); -+ assert!( -+ (actual_score - expected_score).abs() < 1e-6, -+ "id={id}, full={expected_score}, split={actual_score}" -+ ); -+ } -+ -+ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; -+ let duplicate_segments = [segment_uuids[0], segment_uuids[0]]; -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_fts_index_segments( -+ scanner, -+ duplicate_segments.as_ptr().cast::(), -+ duplicate_segments.len(), -+ ) -+ }, -+ -1 -+ ); -+ assert!(unsafe { lance_scanner_set_fts_query_context(scanner, ptr::null()) } < 0); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ -+ let unknown_segment_scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(unknown_segment_scanner, context) }, -+ 0 -+ ); -+ let unknown_uuid = [0_u8; 16]; -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_fts_index_segments(unknown_segment_scanner, unknown_uuid.as_ptr(), 1) -+ }, -+ 0, -+ "membership is validated against the attached context at scan time" -+ ); -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(unknown_segment_scanner, &mut stream) }, -+ -1 -+ ); -+ unsafe { lance_scanner_close(unknown_segment_scanner) }; -+ -+ let independently_reopened = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ assert!(!independently_reopened.is_null()); -+ assert_eq!( -+ unsafe { lance_dataset_version(independently_reopened) }, -+ unsafe { lance_dataset_version(dataset) }, -+ "the identity check must reject equal URI/version locator metadata" -+ ); -+ let reopened_scanner = -+ unsafe { lance_scanner_new(independently_reopened, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(reopened_scanner, context) }, -+ -1, -+ "an independently opened dataset must not reuse the prepared context" -+ ); -+ let message = unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()) -+ .to_string_lossy() -+ .into_owned() -+ }; -+ assert!( -+ message.contains("same process-local dataset snapshot"), -+ "{message}" -+ ); -+ unsafe { lance_scanner_close(reopened_scanner) }; -+ unsafe { lance_dataset_close(independently_reopened) }; -+ -+ let old_snapshot = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 2) }; -+ assert!(!old_snapshot.is_null()); -+ let old_snapshot_scanner = unsafe { lance_scanner_new(old_snapshot, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(old_snapshot_scanner, context) }, -+ -1, -+ "a context must not be attached to a different dataset version" -+ ); -+ unsafe { lance_scanner_close(old_snapshot_scanner) }; -+ unsafe { lance_dataset_close(old_snapshot) }; -+ -+ unsafe { lance_fts_query_context_close(context) }; -+ unsafe { lance_dataset_close(dataset) }; -+} -+ -+#[test] -+fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { -+ let (_tmp, uri) = create_test_dataset(); -+ let uri_c = c_str(&uri); -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ let column = c_str("name"); -+ let query = c_str("alice"); -+ let empty = c_str(""); -+ -+ assert!( -+ unsafe { -+ lance_dataset_prepare_fts_query( -+ ptr::null(), -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ } -+ .is_null() -+ ); -+ assert!( -+ unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ empty.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ } -+ .is_null() -+ ); -+ assert!( -+ unsafe { lance_dataset_prepare_fts_query(dataset, column.as_ptr(), empty.as_ptr(), 0, 0) } -+ .is_null() -+ ); -+ assert!( -+ unsafe { lance_dataset_prepare_fts_query(dataset, column.as_ptr(), query.as_ptr(), 0, 99) } -+ .is_null() -+ ); -+ assert!( -+ unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 1, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ } -+ .is_null() -+ ); -+ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); -+ let message = unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()) -+ .to_string_lossy() -+ .into_owned() -+ }; -+ assert!( -+ message.contains("max_fuzzy_distance must be 0"), -+ "{message}" -+ ); -+ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_index_segments(scanner, ptr::null(), 1) }, -+ -1 -+ ); -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_index_segments(scanner, ptr::null(), 0) }, -+ 0 -+ ); -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_fts_query_context_close(ptr::null_mut()) }; -+ unsafe { lance_dataset_close(dataset) }; -+} -+ - #[test] - fn test_nearest_after_fts_is_rejected() { - let (_tmp, uri) = create_vector_dataset(64, 8); -@@ -6418,18 +6978,17 @@ fn test_scanner_with_substrait_filter() { - - #[test] - fn test_scanner_substrait_filter_overrides_sql_filter() { -- // If both SQL and Substrait filters are set, Substrait wins (last write). -+ // If both primary filters are set, Substrait wins. - let (_tmp, uri) = create_test_dataset(); - let c_uri = c_str(&uri); - let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; - assert!(!ds.is_null()); - -- // Start with SQL filter "id < 0" (matches 0 rows). - let sql = c_str("id < 0"); - let scanner = unsafe { lance_scanner_new(ds, ptr::null(), sql.as_ptr()) }; - assert!(!scanner.is_null()); - -- // Override with Substrait filter "id > 3" (matches 2 rows). -+ // Attach Substrait filter "id > 3" (matches id=4 and id=5). - let bytes = substrait_id_gt_3(); - let rc = unsafe { lance_scanner_set_substrait_filter(scanner, bytes.as_ptr(), bytes.len()) }; - assert_eq!(rc, 0); -@@ -6446,6 +7005,210 @@ fn test_scanner_substrait_filter_overrides_sql_filter() { - unsafe { lance_dataset_close(ds) }; - } - -+#[test] -+fn test_scanner_additional_sql_filters_are_anded_with_substrait() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ -+ let bytes = substrait_id_gt_3(); -+ assert_eq!( -+ unsafe { lance_scanner_set_substrait_filter(scanner, bytes.as_ptr(), bytes.len()) }, -+ 0 -+ ); -+ for sql in [c_str("id < 6"), c_str("id < 5")] { -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, sql.as_ptr()) }, -+ 0 -+ ); -+ } -+ -+ let mut ffi_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, -+ 0 -+ ); -+ -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); -+ let total_rows: usize = reader.map(|r| r.unwrap().num_rows()).sum(); -+ assert_eq!(total_rows, 1, "id > 3 AND id < 6 AND id < 5 matches id=4"); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_additional_sql_filter_preserves_metadata_primary_filter() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let primary = c_str( -+ "_rowid IS NOT NULL AND _rowaddr IS NOT NULL \ -+ AND _row_created_at_version IS NOT NULL \ -+ AND _row_last_updated_at_version IS NOT NULL", -+ ); -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), primary.as_ptr()) }; -+ assert!(!scanner.is_null()); -+ -+ let additional = c_str("id > 3"); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, additional.as_ptr()) }, -+ 0 -+ ); -+ -+ let mut ffi_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, -+ 0 -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); -+ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert_eq!(total_rows, 2, "metadata predicate AND id > 3"); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_additional_sql_filter_preserves_distance_primary_filter() { -+ let (_tmp, uri) = create_vector_dataset(16, 8); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let primary = c_str("_distance IS NOT NULL"); -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), primary.as_ptr()) }; -+ assert!(!scanner.is_null()); -+ let query = [0.0_f32; 8]; -+ let column = c_str("embedding"); -+ assert_eq!( -+ unsafe { -+ lance_scanner_nearest( -+ scanner, -+ column.as_ptr(), -+ query.as_ptr().cast(), -+ query.len(), -+ LanceDataType::Float32 as i32, -+ 16, -+ ) -+ }, -+ 0 -+ ); -+ let additional = c_str("id < 3"); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, additional.as_ptr()) }, -+ 0 -+ ); -+ -+ let mut ffi_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, -+ 0 -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); -+ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert_eq!(total_rows, 3, "_distance predicate AND id < 3"); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_additional_sql_filter_preserves_score_primary_filter() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ assert!(!ds.is_null()); -+ -+ let column = c_str("name"); -+ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); -+ assert_eq!( -+ unsafe { -+ lance_dataset_create_scalar_index( -+ ds, -+ column.as_ptr(), -+ ptr::null(), -+ LanceScalarIndexType::Inverted as i32, -+ inverted_params.as_ptr(), -+ false, -+ ) -+ }, -+ 0 -+ ); -+ -+ let primary = c_str("_score IS NOT NULL"); -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), primary.as_ptr()) }; -+ assert!(!scanner.is_null()); -+ let query = c_str("alice"); -+ let columns = [column.as_ptr(), ptr::null()]; -+ assert_eq!( -+ unsafe { lance_scanner_full_text_search(scanner, query.as_ptr(), columns.as_ptr(), 0) }, -+ 0 -+ ); -+ let additional = c_str("id >= 1"); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, additional.as_ptr()) }, -+ 0 -+ ); -+ -+ let mut ffi_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, -+ 0 -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap(); -+ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert_eq!(total_rows, 1, "_score predicate AND id >= 1"); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ -+#[test] -+fn test_scanner_additional_sql_filter_rejects_invalid_inputs() { -+ let (_tmp, uri) = create_test_dataset(); -+ let c_uri = c_str(&uri); -+ let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -+ let scanner = unsafe { lance_scanner_new(ds, ptr::null(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ -+ let filter = c_str("id > 3"); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(ptr::null_mut(), filter.as_ptr()) }, -+ -1 -+ ); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, ptr::null()) }, -+ -1 -+ ); -+ let empty = c_str(""); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, empty.as_ptr()) }, -+ -1 -+ ); -+ -+ let mut ffi_stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut ffi_stream) }, -+ 0 -+ ); -+ assert_eq!( -+ unsafe { lance_scanner_additional_sql_filter(scanner, filter.as_ptr()) }, -+ -1, -+ "additional filters must be rejected after the scan starts" -+ ); -+ drop(unsafe { ArrowArrayStreamReader::from_raw(&mut ffi_stream) }.unwrap()); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_dataset_close(ds) }; -+} -+ - #[test] - fn test_scanner_set_substrait_filter_invalid_inputs() { - let (_tmp, uri) = create_test_dataset(); -@@ -10388,8 +11151,8 @@ fn test_add_columns_nulls_released_schema_rejected() { - #[test] - fn test_add_columns_nulls_non_utf8_format_rejected() { - // A non-NULL but non-UTF-8 top-level `format` must be rejected at the FFI -- // boundary rather than aborting via arrow-rs's `format().to_str().expect()` -- // under `panic = "abort"`. -+ // boundary rather than reaching arrow-rs's `format().to_str().expect()` -+ // and being downgraded from a precise InvalidArgument to Panic. - let (_tmp, uri) = create_large_dataset(2); - let c_uri = c_str(&uri); - let ds = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; -diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp -index 3293bfb..b5d090a 100644 ---- a/tests/cpp/test_cpp_api.cpp -+++ b/tests/cpp/test_cpp_api.cpp -@@ -12,8 +12,11 @@ - - #include "lance/lance.hpp" - #include -+#include -+#include - #include - #include -+#include - #include - #include - #include -@@ -44,6 +47,29 @@ static void capture_scan_statistics( - captured->bytes_read = statistics->bytes_read; - } - -+struct AsyncScanCapture { -+ std::mutex mutex; -+ std::condition_variable ready; -+ bool completed = false; -+ int32_t status = -1; -+ ArrowArrayStream* stream = nullptr; -+}; -+ -+static void capture_async_scan( -+ void* callback_ctx, -+ int32_t status, -+ void* result) noexcept { -+ if (!callback_ctx) return; -+ auto* captured = static_cast(callback_ctx); -+ { -+ std::lock_guard lock(captured->mutex); -+ captured->status = status; -+ captured->stream = static_cast(result); -+ captured->completed = true; -+ } -+ captured->ready.notify_one(); -+} -+ - static void test_dataset_open(const std::string& uri) { - TEST(test_dataset_open); - -@@ -121,6 +147,47 @@ static void test_scanner_fluent(const std::string& uri) { - PASS(); - } - -+static void test_scanner_async_stream_ownership(const std::string& uri) { -+ TEST(test_scanner_async_stream_ownership); -+ -+ auto ds = lance::Dataset::open(uri); -+ auto scanner = ds.scan(); -+ AsyncScanCapture captured; -+ scanner.scan_async(capture_async_scan, &captured); -+ -+ ArrowArrayStream* stream = nullptr; -+ { -+ std::unique_lock lock(captured.mutex); -+ bool completed = captured.ready.wait_for( -+ lock, std::chrono::seconds(30), [&captured] { -+ return captured.completed; -+ }); -+ assert(completed && "async scan callback timed out"); -+ assert(captured.status == 0); -+ assert(captured.stream != nullptr); -+ stream = captured.stream; -+ } -+ -+ uint64_t total = 0; -+ while (true) { -+ ArrowArray array; -+ memset(&array, 0, sizeof(array)); -+ int rc = stream->get_next(stream, &array); -+ assert(rc == 0); -+ if (!array.release) break; -+ total += static_cast(array.length); -+ array.release(&array); -+ } -+ assert(total > 0); -+ -+ // This releases the stream contents (if still live) and the separate -+ // library-allocated outer structure. It is also explicitly NULL-safe. -+ lance::scanner_async_stream_free(stream); -+ lance::scanner_async_stream_free(nullptr); -+ -+ PASS(); -+} -+ - static void test_dataset_take(const std::string& uri) { - TEST(test_dataset_take); - -@@ -194,6 +261,15 @@ static void test_raii_cleanup(const std::string& uri) { - auto ds1 = lance::Dataset::open(uri); - auto ds2 = std::move(ds1); - assert(ds2.count_rows() > 0); -+ -+ bool moved_from_version_threw = false; -+ try { -+ (void)ds1.version(); -+ } catch (const lance::Error& e) { -+ moved_from_version_threw = true; -+ assert(e.code == LANCE_ERR_INVALID_ARGUMENT); -+ } -+ assert(moved_from_version_threw); - } - - PASS(); -@@ -832,6 +908,7 @@ int main(int argc, char** argv) { - test_dataset_open(uri); - test_dataset_schema(uri); - test_scanner_fluent(uri); -+ test_scanner_async_stream_ownership(uri); - test_dataset_take(uri); - test_dataset_take_rows(uri); - test_raii_cleanup(uri); -diff --git a/tests/panic_stream_guard.rs b/tests/panic_stream_guard.rs -index f4690c5..12efd0d 100644 ---- a/tests/panic_stream_guard.rs -+++ b/tests/panic_stream_guard.rs -@@ -40,8 +40,16 @@ - //! host. The guard's `Drop` detaches the inner stream and contains - //! cleanup. Runs in a child process, asserting a clean exit AND that - //! the destructor panic really fired (caught). -+//! -+//! 5. Regular errors containing NUL are sanitized before arrow-rs formats -+//! them inside `get_next`. -+//! 6. A panic from an external error's `Display` is caught, reported as one -+//! terminal stream error, and poisons the scanner. -+//! 7. An unexportable schema is rejected while still inside the Rust guard, -+//! before arrow-rs's non-unwinding `get_schema` callback is exposed. - - use std::ffi::CStr; -+use std::panic::{AssertUnwindSafe, catch_unwind}; - use std::pin::Pin; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; -@@ -49,7 +57,7 @@ use std::task::{Context, Poll}; - - use arrow::array::{Int32Array, RecordBatch}; - use arrow::datatypes::{DataType, Field, Schema, SchemaRef}; --use arrow::ffi::FFI_ArrowArray; -+use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema}; - use arrow::ffi_stream::FFI_ArrowArrayStream; - use futures::Stream; - use lance_c::stream_guard::GuardedReader; -@@ -91,6 +99,17 @@ impl Stream for PanicOnSecondPoll { - /// destructor reached from the Arrow C `release` callback. - struct PanicOnDrop; - -+#[derive(Debug)] -+struct PanickingDisplay; -+ -+impl std::fmt::Display for PanickingDisplay { -+ fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -+ panic!("simulated panic while formatting a stream error") -+ } -+} -+ -+impl std::error::Error for PanickingDisplay {} -+ - impl Stream for PanicOnDrop { - type Item = lance_core::Result; - -@@ -112,6 +131,11 @@ unsafe fn c_get_next(stream: *mut FFI_ArrowArrayStream, array: *mut FFI_ArrowArr - unsafe { get_next(stream, array) } - } - -+unsafe fn c_get_schema(stream: *mut FFI_ArrowArrayStream, schema: *mut FFI_ArrowSchema) -> i32 { -+ let get_schema = unsafe { (*stream).get_schema }.expect("get_schema callback is NULL"); -+ unsafe { get_schema(stream, schema) } -+} -+ - unsafe fn c_get_last_error(stream: *mut FFI_ArrowArrayStream) -> Option { - let get_last_error = - unsafe { (*stream).get_last_error }.expect("get_last_error callback is NULL"); -@@ -241,6 +265,120 @@ fn guarded_stream_maps_panic_to_c_stream_error() { - ); - } - -+#[test] -+fn guarded_stream_sanitizes_nul_in_regular_error() { -+ if std::env::var("POC_CHILD_NUL_ERROR").is_err() { -+ let output = run_child( -+ "guarded_stream_sanitizes_nul_in_regular_error", -+ "POC_CHILD_NUL_ERROR", -+ ); -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ assert!( -+ output.status.success(), -+ "guarded child must exit cleanly, got status {:?}\nstderr:\n{stderr}", -+ output.status -+ ); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( -+ "ordinary error with a NUL: bo\0om".into(), -+ ))]); -+ let (_rt, mut ffi) = guarded_export(stream, Arc::clone(&scanner_poison)); -+ let mut array = FFI_ArrowArray::empty(); -+ -+ let rc = unsafe { c_get_next(&mut ffi, &mut array) }; -+ assert_ne!(rc, 0, "the ordinary stream error must reach Arrow C"); -+ let msg = unsafe { c_get_last_error(&mut ffi) }.expect("get_last_error returned NULL"); -+ assert!(msg.contains("bo\\0om"), "NUL must be escaped, got: {msg:?}"); -+ assert!( -+ !scanner_poison.load(Ordering::SeqCst), -+ "an ordinary stream error must not poison the scanner" -+ ); -+} -+ -+#[test] -+fn guarded_stream_catches_panicking_error_display() { -+ if std::env::var("POC_CHILD_DISPLAY_ERROR").is_err() { -+ let output = run_child( -+ "guarded_stream_catches_panicking_error_display", -+ "POC_CHILD_DISPLAY_ERROR", -+ ); -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ assert!( -+ output.status.success(), -+ "guarded child must exit cleanly, got status {:?}\nstderr:\n{stderr}", -+ output.status -+ ); -+ assert!( -+ stderr.contains("simulated panic while formatting a stream error"), -+ "the formatting panic must have fired and been caught\nstderr:\n{stderr}" -+ ); -+ return; -+ } -+ -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let stream = futures::stream::iter(vec![Err(lance_core::Error::invalid_input_source( -+ Box::new(PanickingDisplay), -+ ))]); -+ let (_rt, mut ffi) = guarded_export(stream, Arc::clone(&scanner_poison)); -+ let mut array = FFI_ArrowArray::empty(); -+ -+ let rc = unsafe { c_get_next(&mut ffi, &mut array) }; -+ assert_ne!(rc, 0, "the caught panic must reach Arrow C as an error"); -+ let msg = unsafe { c_get_last_error(&mut ffi) }.expect("get_last_error returned NULL"); -+ assert!( -+ msg.contains("simulated panic while formatting a stream error"), -+ "panic message should propagate to get_last_error, got: {msg}" -+ ); -+ assert!( -+ scanner_poison.load(Ordering::SeqCst), -+ "a formatting panic must poison the owning scanner" -+ ); -+} -+ -+#[test] -+fn guarded_stream_rejects_nul_schema_before_arrow_callback() { -+ if std::env::var("POC_CHILD_NUL_SCHEMA").is_err() { -+ let output = run_child( -+ "guarded_stream_rejects_nul_schema_before_arrow_callback", -+ "POC_CHILD_NUL_SCHEMA", -+ ); -+ let stderr = String::from_utf8_lossy(&output.stderr); -+ assert!( -+ output.status.success(), -+ "schema validation must fail before Arrow's callback can abort, got status {:?}\nstderr:\n{stderr}", -+ output.status -+ ); -+ return; -+ } -+ -+ let schema = Arc::new(Schema::new(vec![Field::new( -+ "field\0name", -+ DataType::Int32, -+ false, -+ )])); -+ let rt = tokio::runtime::Runtime::new().unwrap(); -+ let scanner_poison = Arc::new(AtomicBool::new(false)); -+ let outcome = catch_unwind(AssertUnwindSafe(|| { -+ let reader = GuardedReader::new( -+ futures::stream::empty::>(), -+ schema, -+ rt.handle().clone(), -+ scanner_poison, -+ ); -+ let mut ffi = FFI_ArrowArrayStream::new(Box::new(reader)); -+ let mut ffi_schema = FFI_ArrowSchema::empty(); -+ let rc = unsafe { c_get_schema(&mut ffi, &mut ffi_schema) }; -+ panic!("invalid schema reached Arrow callback and returned rc={rc}"); -+ })); -+ assert!( -+ outcome.is_err(), -+ "invalid schema must be rejected while the Rust FFI guard can still catch it" -+ ); -+} -+ - /// A `get_next` call made from a thread that is currently driving a Tokio - /// runtime (inside `Runtime::block_on` or a spawned task — a merely - /// `enter()`ed context does not trip tokio's check) makes `Handle::block_on` diff --git a/thirdparty/patches/lance-c-0.1.8-pr-69.patch b/thirdparty/patches/lance-c-0.1.8-pr-69.patch new file mode 100644 index 00000000000000..ee38eb33bcb2fd --- /dev/null +++ b/thirdparty/patches/lance-c-0.1.8-pr-69.patch @@ -0,0 +1,653 @@ +From a4d6e489c627fe4b0e49d9a2991c9436313debc0 Mon Sep 17 00:00:00 2001 +From: zhangstar333 +Date: Tue, 1 Sep 2026 11:15:00 +0800 +Subject: [PATCH 1/2] update + +--- + src/fts_query.rs | 2 ++ + src/scanner.rs | 44 ++++++++++++++++++++++--- + tests/c_api_test.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 120 insertions(+), 4 deletions(-) + +diff --git a/src/fts_query.rs b/src/fts_query.rs +index cd194c7..e85ed99 100644 +--- a/src/fts_query.rs ++++ b/src/fts_query.rs +@@ -54,6 +54,7 @@ pub(crate) struct FtsQueryContextInner { + pub(crate) query: FullTextSearchQuery, + pub(crate) segments: Vec, + pub(crate) scorer: Arc, ++ pub(crate) has_unindexed_fragments: bool, + } + + impl FtsQueryContextInner { +@@ -216,6 +217,7 @@ async fn prepare_fts_query_context( + query, + segments, + scorer, ++ has_unindexed_fragments: !unindexed_fragment_ids.is_empty(), + }) + } + +diff --git a/src/scanner.rs b/src/scanner.rs +index 5b8c34e..f60f0c5 100644 +--- a/src/scanner.rs ++++ b/src/scanner.rs +@@ -179,6 +179,45 @@ impl LanceScanner { + Ok(()) + } + ++ /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the ++ /// selected committed segments. This is deliberately separate from ++ /// `fast_search`: that option is scanner-wide, changes unrelated scalar ++ /// index fallback behavior, and also forces `_rowid` into the output. ++ fn apply_prepared_fts_fragment_filter( ++ &self, ++ scanner: &mut lance::dataset::scanner::Scanner, ++ context: &FtsQueryContextInner, ++ segments: &[IndexMetadata], ++ ) -> Result<()> { ++ if !context.has_unindexed_fragments { ++ return Ok(()); ++ } ++ ++ let mut selected_fragment_ids = std::collections::HashSet::new(); ++ for segment in segments { ++ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { ++ lance_core::Error::internal(format!( ++ "prepared FTS segment {} lost its validated fragment coverage", ++ segment.uuid ++ )) ++ })?; ++ selected_fragment_ids.extend(fragment_bitmap.iter()); ++ } ++ ++ let selected_fragments = self ++ .dataset ++ .get_fragments() ++ .into_iter() ++ .filter(|fragment| { ++ u32::try_from(fragment.id()) ++ .is_ok_and(|fragment_id| selected_fragment_ids.contains(&fragment_id)) ++ }) ++ .map(|fragment| fragment.metadata().clone()) ++ .collect(); ++ scanner.with_fragments(selected_fragments); ++ Ok(()) ++ } ++ + fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { + if let Some(substrait) = &self.substrait_filter { + scanner.filter_substrait(substrait)?; +@@ -282,11 +321,8 @@ impl LanceScanner { + let distributed_fts = if let Some(context) = &self.fts_context { + context.validate_dataset_identity(&self.dataset)?; + let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; ++ self.apply_prepared_fts_fragment_filter(&mut scanner, context, &segments)?; + scanner.full_text_search(context.query.clone())?; +- // Both STRICT and INDEX_ONLY context scans must use only the +- // committed segments pinned in the context. In STRICT mode all +- // current fragments were already proven covered during prepare. +- scanner.fast_search(); + Some(PreparedFtsExecution { + context: Arc::clone(context), + segments, +diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs +index 74b9f85..3627c4e 100644 +--- a/tests/c_api_test.rs ++++ b/tests/c_api_test.rs +@@ -5762,6 +5762,84 @@ fn load_fts_segment_uuids(uri: &str, column: &str) -> Vec<[u8; 16]> { + }) + } + ++#[test] ++fn test_prepared_fts_row_id_output_is_explicit() { ++ let (_tmp, uri) = create_test_dataset(); ++ let uri_c = c_str(&uri); ++ let column = c_str("name"); ++ let query = c_str("alice"); ++ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); ++ ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ dataset, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ inverted_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ let context = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!context.is_null(), "{}", unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() ++ }); ++ ++ let id = c_str("id"); ++ let columns = [id.as_ptr(), ptr::null()]; ++ let scan_schema = |with_row_id: bool| { ++ let scanner = unsafe { lance_scanner_new(dataset, columns.as_ptr(), ptr::null()) }; ++ assert!(!scanner.is_null()); ++ if with_row_id { ++ assert_eq!(unsafe { lance_scanner_with_row_id(scanner, true) }, 0); ++ } ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, ++ 0 ++ ); ++ let mut stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, ++ 0, ++ "{}", ++ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; ++ let schema = reader.schema(); ++ let rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert!(rows > 0); ++ unsafe { lance_scanner_close(scanner) }; ++ schema ++ }; ++ ++ let without_row_id = scan_schema(false); ++ assert_eq!(without_row_id.fields().len(), 2); ++ assert!(without_row_id.field_with_name("id").is_ok()); ++ assert!(without_row_id.field_with_name("_score").is_ok()); ++ assert!(without_row_id.field_with_name("_rowid").is_err()); ++ ++ let with_row_id = scan_schema(true); ++ assert_eq!(with_row_id.fields().len(), 3); ++ assert!(with_row_id.field_with_name("id").is_ok()); ++ assert!(with_row_id.field_with_name("_score").is_ok()); ++ assert!(with_row_id.field_with_name("_rowid").is_ok()); ++ ++ unsafe { lance_fts_query_context_close(context) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ + #[test] + fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { + let (_tmp, uri) = create_test_dataset(); + +From 6f0fae4cc51bf144685564b1d2e9f6f7afc71f8a Mon Sep 17 00:00:00 2001 +From: zhangstar333 +Date: Tue, 1 Sep 2026 12:34:05 +0800 +Subject: [PATCH 2/2] update + +--- + src/fts_query.rs | 2 - + src/scanner.rs | 253 ++++++++++++++++++++++++++++++++++---------- + tests/c_api_test.rs | 79 ++++++++++++++ + 3 files changed, 279 insertions(+), 55 deletions(-) + +diff --git a/src/fts_query.rs b/src/fts_query.rs +index e85ed99..cd194c7 100644 +--- a/src/fts_query.rs ++++ b/src/fts_query.rs +@@ -54,7 +54,6 @@ pub(crate) struct FtsQueryContextInner { + pub(crate) query: FullTextSearchQuery, + pub(crate) segments: Vec, + pub(crate) scorer: Arc, +- pub(crate) has_unindexed_fragments: bool, + } + + impl FtsQueryContextInner { +@@ -217,7 +216,6 @@ async fn prepare_fts_query_context( + query, + segments, + scorer, +- has_unindexed_fragments: !unindexed_fragment_ids.is_empty(), + }) + } + +diff --git a/src/scanner.rs b/src/scanner.rs +index f60f0c5..0c29b17 100644 +--- a/src/scanner.rs ++++ b/src/scanner.rs +@@ -12,13 +12,13 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + use arrow::ffi_stream::FFI_ArrowArrayStream; + use arrow_schema::SchemaRef; +-use datafusion::physical_plan::ExecutionPlan; ++use datafusion::physical_plan::{ExecutionPlan, empty::EmptyExec}; + use futures::{FutureExt, Stream, StreamExt}; + use lance::Dataset; + use lance::dataset::scanner::{ + DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts, + }; +-use lance::io::exec::fts::MatchQueryExec; ++use lance::io::exec::fts::{FlatMatchQueryExec, MatchQueryExec}; + use lance_core::Result; + use lance_index::scalar::FullTextSearchQuery; + use lance_io::stream::RecordBatchStream; +@@ -179,45 +179,6 @@ impl LanceScanner { + Ok(()) + } + +- /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the +- /// selected committed segments. This is deliberately separate from +- /// `fast_search`: that option is scanner-wide, changes unrelated scalar +- /// index fallback behavior, and also forces `_rowid` into the output. +- fn apply_prepared_fts_fragment_filter( +- &self, +- scanner: &mut lance::dataset::scanner::Scanner, +- context: &FtsQueryContextInner, +- segments: &[IndexMetadata], +- ) -> Result<()> { +- if !context.has_unindexed_fragments { +- return Ok(()); +- } +- +- let mut selected_fragment_ids = std::collections::HashSet::new(); +- for segment in segments { +- let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { +- lance_core::Error::internal(format!( +- "prepared FTS segment {} lost its validated fragment coverage", +- segment.uuid +- )) +- })?; +- selected_fragment_ids.extend(fragment_bitmap.iter()); +- } +- +- let selected_fragments = self +- .dataset +- .get_fragments() +- .into_iter() +- .filter(|fragment| { +- u32::try_from(fragment.id()) +- .is_ok_and(|fragment_id| selected_fragment_ids.contains(&fragment_id)) +- }) +- .map(|fragment| fragment.metadata().clone()) +- .collect(); +- scanner.with_fragments(selected_fragments); +- Ok(()) +- } +- + fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { + if let Some(substrait) = &self.substrait_filter { + scanner.filter_substrait(substrait)?; +@@ -321,7 +282,6 @@ impl LanceScanner { + let distributed_fts = if let Some(context) = &self.fts_context { + context.validate_dataset_identity(&self.dataset)?; + let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; +- self.apply_prepared_fts_fragment_filter(&mut scanner, context, &segments)?; + scanner.full_text_search(context.query.clone())?; + Some(PreparedFtsExecution { + context: Arc::clone(context), +@@ -361,14 +321,24 @@ impl PreparedScanner { + return self.scanner.try_into_stream().await; + }; + let plan = self.scanner.create_plan().await?; +- let (plan, replaced) = replace_match_query_exec( ++ let selected_segments_have_current_fragments = segments_have_current_fragments( ++ &distributed_fts.context.dataset, ++ &distributed_fts.segments, ++ )?; ++ let (plan, rewritten) = rewrite_prepared_fts_plan( + plan, + &distributed_fts.segments, + &distributed_fts.context.scorer, ++ selected_segments_have_current_fragments, + )?; +- if replaced != 1 { ++ if rewritten.match_query_execs > 1 ++ || rewritten.flat_match_query_execs > 1 ++ || rewritten.match_query_execs + rewritten.flat_match_query_execs == 0 ++ || (selected_segments_have_current_fragments && rewritten.match_query_execs != 1) ++ { + return Err(lance_core::Error::internal(format!( +- "expected exactly one MatchQueryExec in prepared FTS plan, replaced {replaced}" ++ "unexpected prepared FTS plan for selected segments with current fragment coverage {selected_segments_have_current_fragments}: rewrote {} MatchQueryExec node(s) and removed {} FlatMatchQueryExec node(s)", ++ rewritten.match_query_execs, rewritten.flat_match_query_execs + ))); + } + let stream = lance_datafusion::exec::execute_plan( +@@ -415,22 +385,81 @@ fn select_fts_segments( + Ok(selected) + } + +-fn replace_match_query_exec( ++fn segments_have_current_fragments( ++ dataset: &lance::Dataset, ++ segments: &[IndexMetadata], ++) -> Result { ++ let current_fragment_ids = dataset ++ .get_fragments() ++ .into_iter() ++ .map(|fragment| { ++ u32::try_from(fragment.id()).map_err(|_| { ++ lance_core::Error::internal(format!( ++ "current fragment id {} exceeds the validated u32 FTS coverage range", ++ fragment.id() ++ )) ++ }) ++ }) ++ .collect::>>()?; ++ for segment in segments { ++ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { ++ lance_core::Error::internal(format!( ++ "prepared FTS segment {} lost its validated fragment coverage", ++ segment.uuid ++ )) ++ })?; ++ if fragment_bitmap ++ .iter() ++ .any(|fragment_id| current_fragment_ids.contains(&fragment_id)) ++ { ++ return Ok(true); ++ } ++ } ++ Ok(false) ++} ++ ++#[derive(Default)] ++struct PreparedFtsPlanRewriteCounts { ++ match_query_execs: usize, ++ flat_match_query_execs: usize, ++} ++ ++fn rewrite_prepared_fts_plan( + plan: Arc, + segments: &[IndexMetadata], + scorer: &Arc, +-) -> Result<(Arc, usize)> { ++ selected_segments_have_current_fragments: bool, ++) -> Result<(Arc, PreparedFtsPlanRewriteCounts)> { ++ // Lance's ordinary FTS planner adds a flat-search branch for fragments not ++ // covered by the logical index. A prepared INDEX_ONLY scan must omit that ++ // branch, but using Scanner::with_fragments to do so would turn an ++ // otherwise unfiltered index search into a full row-id prefilter scan. ++ if plan.downcast_ref::().is_some() { ++ return Ok(( ++ Arc::new(EmptyExec::new(plan.schema())), ++ PreparedFtsPlanRewriteCounts { ++ match_query_execs: 0, ++ flat_match_query_execs: 1, ++ }, ++ )); ++ } ++ + let children = plan.children(); +- let mut replaced = 0; ++ let mut rewritten = PreparedFtsPlanRewriteCounts::default(); + let rebuilt = if children.is_empty() { + plan + } else { + let mut new_children = Vec::with_capacity(children.len()); + for child in children { +- let (new_child, child_replaced) = +- replace_match_query_exec(Arc::clone(child), segments, scorer)?; ++ let (new_child, child_rewritten) = rewrite_prepared_fts_plan( ++ Arc::clone(child), ++ segments, ++ scorer, ++ selected_segments_have_current_fragments, ++ )?; + new_children.push(new_child); +- replaced += child_replaced; ++ rewritten.match_query_execs += child_rewritten.match_query_execs; ++ rewritten.flat_match_query_execs += child_rewritten.flat_match_query_execs; + } + plan.with_new_children(new_children).map_err(|error| { + lance_core::Error::internal(format!( +@@ -440,6 +469,10 @@ fn replace_match_query_exec( + }; + + if let Some(exec) = rebuilt.downcast_ref::() { ++ rewritten.match_query_execs += 1; ++ if !selected_segments_have_current_fragments { ++ return Ok((Arc::new(EmptyExec::new(rebuilt.schema())), rewritten)); ++ } + let replacement = MatchQueryExec::new_with_segments( + Arc::clone(exec.dataset()), + exec.query().clone(), +@@ -448,9 +481,9 @@ fn replace_match_query_exec( + segments.to_vec(), + ) + .with_base_scorer(Arc::clone(scorer)); +- return Ok((Arc::new(replacement), replaced + 1)); ++ return Ok((Arc::new(replacement), rewritten)); + } +- Ok((rebuilt, replaced)) ++ Ok((rebuilt, rewritten)) + } + + /// Type of a dynamically named scan metric. +@@ -2118,6 +2151,9 @@ mod tests { + use super::*; + use crate::dataset::{lance_dataset_close, lance_dataset_open}; + use crate::error::{lance_last_error_code, lance_last_error_message}; ++ use crate::fts_query::{ ++ LanceFtsCoverageMode, lance_dataset_prepare_fts_query, lance_fts_query_context_close, ++ }; + use std::ffi::{CStr, CString}; + use std::sync::atomic::{AtomicI32, AtomicUsize}; + use std::sync::{Barrier, mpsc}; +@@ -2125,6 +2161,9 @@ mod tests { + + use arrow_array::{Int32Array, RecordBatch, StringArray}; + use arrow_schema::{DataType, Field, Schema}; ++ use lance::index::DatasetIndexExt; ++ use lance::io::exec::PreFilterSource; ++ use lance_index::{IndexType, scalar::InvertedIndexParams}; + + /// Write a 3-row dataset to a tempdir, returning (tempdir, uri). + fn create_test_dataset() -> (tempfile::TempDir, String) { +@@ -2169,6 +2208,114 @@ mod tests { + .store(true, Ordering::SeqCst); + } + ++ fn prepared_fts_plan_shape(plan: &Arc) -> (usize, usize, usize) { ++ let mut match_query_execs = 0; ++ let mut flat_match_query_execs = 0; ++ let mut filtered_row_id_prefilters = 0; ++ if let Some(exec) = plan.downcast_ref::() { ++ match_query_execs += 1; ++ if matches!(exec.prefilter_source(), PreFilterSource::FilteredRowIds(_)) { ++ filtered_row_id_prefilters += 1; ++ } ++ } ++ if plan.downcast_ref::().is_some() { ++ flat_match_query_execs += 1; ++ } ++ for child in plan.children() { ++ let (child_match, child_flat, child_filtered) = ++ prepared_fts_plan_shape(&Arc::clone(child)); ++ match_query_execs += child_match; ++ flat_match_query_execs += child_flat; ++ filtered_row_id_prefilters += child_filtered; ++ } ++ ( ++ match_query_execs, ++ flat_match_query_execs, ++ filtered_row_id_prefilters, ++ ) ++ } ++ ++ #[test] ++ fn prepared_fts_index_only_plan_does_not_scan_indexed_fragment_row_ids() { ++ let (_tmp, uri) = create_test_dataset(); ++ block_on(async { ++ let mut dataset = Dataset::open(&uri).await.unwrap(); ++ dataset ++ .create_index( ++ &["name"], ++ IndexType::Inverted, ++ None, ++ &InvertedIndexParams::default(), ++ false, ++ ) ++ .await ++ .unwrap(); ++ ++ let schema = Arc::new(Schema::new(vec![ ++ Field::new("id", DataType::Int32, false), ++ Field::new("name", DataType::Utf8, true), ++ ])); ++ let batch = RecordBatch::try_new( ++ schema.clone(), ++ vec![ ++ Arc::new(Int32Array::from(vec![4])), ++ Arc::new(StringArray::from(vec!["a"])), ++ ], ++ ) ++ .unwrap(); ++ dataset ++ .append( ++ arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema), ++ None, ++ ) ++ .await ++ .unwrap(); ++ }); ++ ++ let (dataset, scanner) = open_dataset_and_scanner(&uri); ++ let column = CString::new("name").unwrap(); ++ let query = CString::new("a").unwrap(); ++ let context = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::IndexOnly as i32, ++ ) ++ }; ++ assert!(!context.is_null()); ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, ++ 0 ++ ); ++ ++ let prepared = unsafe { &*scanner }.build_scanner().unwrap(); ++ let distributed = prepared.distributed_fts.as_ref().unwrap(); ++ let segments = distributed.segments.clone(); ++ let scorer = Arc::clone(&distributed.context.scorer); ++ let plan = block_on(prepared.scanner.create_plan()).unwrap(); ++ assert_eq!( ++ prepared_fts_plan_shape(&plan), ++ (1, 1, 0), ++ "an unfiltered prepared FTS plan must not materialize selected fragment row IDs" ++ ); ++ ++ let has_current_fragments = ++ segments_have_current_fragments(&distributed.context.dataset, &segments).unwrap(); ++ let (rewritten, counts) = ++ rewrite_prepared_fts_plan(plan, &segments, &scorer, has_current_fragments).unwrap(); ++ assert_eq!(counts.match_query_execs, 1); ++ assert_eq!(counts.flat_match_query_execs, 1); ++ assert_eq!(prepared_fts_plan_shape(&rewritten), (1, 0, 0)); ++ ++ unsafe { ++ lance_scanner_close(scanner); ++ lance_fts_query_context_close(context); ++ lance_dataset_close(dataset); ++ } ++ } ++ + /// Assert the pending thread-local error is `Panic` carrying the poison + /// message; consumes it so the next assertion starts from a clean slate. + fn assert_poison_error_pending() { +diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs +index 3627c4e..8805764 100644 +--- a/tests/c_api_test.rs ++++ b/tests/c_api_test.rs +@@ -5943,6 +5943,85 @@ fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { + unsafe { lance_dataset_close(dataset) }; + } + ++#[test] ++fn test_prepared_fts_index_only_empty_segment_returns_empty_shard() { ++ use lance::index::DatasetIndexExt; ++ use lance_index::{IndexType, scalar::InvertedIndexParams}; ++ ++ let (_tmp, uri) = create_test_dataset(); ++ lance_c::runtime::block_on(async { ++ let mut dataset = Dataset::open(&uri).await.unwrap(); ++ let params = InvertedIndexParams::default(); ++ dataset ++ .create_index_builder(&["name"], IndexType::Inverted, ¶ms) ++ .name("empty_name_fts".to_string()) ++ .train(false) ++ .await ++ .unwrap(); ++ let segments = dataset ++ .load_indices_by_name("empty_name_fts") ++ .await ++ .unwrap(); ++ assert_eq!(segments.len(), 1); ++ assert!( ++ segments[0] ++ .fragment_bitmap ++ .as_ref() ++ .is_some_and(|fragment_bitmap| fragment_bitmap.is_empty()) ++ ); ++ }); ++ ++ let uri_c = c_str(&uri); ++ let column = c_str("name"); ++ let query = c_str("alice"); ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ let context = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::IndexOnly as i32, ++ ) ++ }; ++ assert!(!context.is_null(), "{}", unsafe { ++ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() ++ }); ++ let segment_uuids = load_fts_segment_uuids(&uri, "name"); ++ assert_eq!(segment_uuids.len(), 1); ++ ++ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; ++ assert_eq!( ++ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, ++ 0 ++ ); ++ assert_eq!( ++ unsafe { ++ lance_scanner_set_fts_index_segments( ++ scanner, ++ segment_uuids.as_ptr().cast::(), ++ segment_uuids.len(), ++ ) ++ }, ++ 0 ++ ); ++ ++ let mut stream = FFI_ArrowArrayStream::empty(); ++ assert_eq!( ++ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, ++ 0, ++ "{}", ++ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } ++ ); ++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; ++ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); ++ assert_eq!(total_rows, 0); ++ ++ unsafe { lance_scanner_close(scanner) }; ++ unsafe { lance_fts_query_context_close(context) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ + #[test] + fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() { + use lance::index::DatasetIndexExt; diff --git a/thirdparty/vars.sh b/thirdparty/vars.sh index 219bdef38b03a8..af714ab9dc4ac5 100644 --- a/thirdparty/vars.sh +++ b/thirdparty/vars.sh @@ -552,10 +552,10 @@ PUGIXML_SOURCE=pugixml-1.15 PUGIXML_MD5SUM="3b894c29455eb33a40b165c6e2de5895" # lance-c -LANCE_C_DOWNLOAD="https://github.com/lance-format/lance-c/archive/refs/tags/v0.1.7.tar.gz" -LANCE_C_NAME="lance-c-v0.1.7.tar.gz" -LANCE_C_SOURCE="lance-c-0.1.7" -LANCE_C_MD5SUM="15ef7cd20a2e1606384251cb2d41d42f" +LANCE_C_DOWNLOAD="https://github.com/lance-format/lance-c/archive/refs/tags/v0.1.8.tar.gz" +LANCE_C_NAME="lance-c-v0.1.8.tar.gz" +LANCE_C_SOURCE="lance-c-0.1.8" +LANCE_C_MD5SUM="2a4af9398cdec19d5d379a27353b1266" # all thirdparties which need to be downloaded is set in array TP_ARCHIVES export TP_ARCHIVES=( From 94d36572ebf20498af54d6b51faedec23e16b7e1 Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Thu, 3 Sep 2026 17:44:15 +0800 Subject: [PATCH 5/7] add fts more --- be/src/format_v2/table/lance_reader.cpp | 63 +- be/test/format_v2/table/lance_reader_test.cpp | 148 ++- .../lance/source/LanceScanNode.java | 12 + .../FullTextSearchTableValuedFunction.java | 63 +- .../lance/source/LanceScanNodeTest.java | 50 +- ...FullTextSearchTableValuedFunctionTest.java | 22 + gensrc/thrift/PlanNodes.thrift | 33 +- .../lance/test_lance_full_text_search.out | 20 +- .../lance/test_lance_full_text_search.groovy | 41 + thirdparty/download-thirdparty.sh | 1 + thirdparty/patches/lance-c-0.1.8-pr-74.patch | 1159 +++++++++++++++++ 11 files changed, 1601 insertions(+), 11 deletions(-) create mode 100644 thirdparty/patches/lance-c-0.1.8-pr-74.patch diff --git a/be/src/format_v2/table/lance_reader.cpp b/be/src/format_v2/table/lance_reader.cpp index c46700fd0fe352..84866ecc24c4a9 100644 --- a/be/src/format_v2/table/lance_reader.cpp +++ b/be/src/format_v2/table/lance_reader.cpp @@ -168,6 +168,19 @@ Status LanceTableReader::init(TableReadOptions&& options) { _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)); @@ -541,6 +554,41 @@ Status LanceTableReader::_validate_external_search_request() const { 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"); @@ -631,8 +679,19 @@ Status LanceTableReader::_prepare_fts_query_context() { // 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. - _fts_query_context = lance_dataset_prepare_fts_query(_dataset, full_text.column.c_str(), - full_text.query.c_str(), 0, coverage_mode); + 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(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"); } diff --git a/be/test/format_v2/table/lance_reader_test.cpp b/be/test/format_v2/table/lance_reader_test.cpp index 9560baa637a20e..3fe4d01a65fbda 100644 --- a/be/test/format_v2/table/lance_reader_test.cpp +++ b/be/test/format_v2/table/lance_reader_test.cpp @@ -279,13 +279,18 @@ TFileScanRangeParams make_float32_vector_search_params( TFileScanRangeParams make_full_text_search_params( std::string query_text, int64_t top_k, int64_t offset, TFtsCoverageMode::type coverage_mode = TFtsCoverageMode::STRICT, - std::optional filter = std::nullopt) { + std::optional filter = std::nullopt, + TFtsMatchOperator::type match_operator = TFtsMatchOperator::OR, + int32_t max_fuzzy_distance = 0) { TFullTextSearchParams full_text_params; full_text_params.__set_column("body"); full_text_params.__set_query(std::move(query_text)); full_text_params.__set_top_k(top_k); full_text_params.__set_offset(offset); full_text_params.__set_coverage_mode(coverage_mode); + full_text_params.__set_query_type(TFtsQueryType::MATCH); + full_text_params.__set_match_operator(match_operator); + full_text_params.__set_max_fuzzy_distance(max_fuzzy_distance); TExternalSearchQuery query; query.__set_full_text_search(std::move(full_text_params)); @@ -306,6 +311,21 @@ TFileScanRangeParams make_full_text_search_params( return scan_params; } +TFileScanRangeParams make_phrase_search_params( + std::string query_text, int64_t top_k, int64_t offset, int32_t slop, + TFtsCoverageMode::type coverage_mode = TFtsCoverageMode::STRICT, + std::optional filter = std::nullopt) { + auto scan_params = make_full_text_search_params(std::move(query_text), top_k, offset, + coverage_mode, std::move(filter)); + auto& full_text = scan_params.lance_scan_params.external_search_request.search_query + .full_text_search; + full_text.__set_query_type(TFtsQueryType::PHRASE); + full_text.__isset.match_operator = false; + full_text.__isset.max_fuzzy_distance = false; + full_text.__set_phrase_slop(slop); + return scan_params; +} + Status get_index_segment_uuids(const std::filesystem::path& dataset_uri, const char* index_name, std::vector* encoded_uuids) { std::unique_ptr dataset( @@ -378,6 +398,12 @@ TEST(LanceTableReaderFullTextSearchTest, ValidatesRequestAndScoreTypeBeforeDatas EXPECT_EQ("FULL_TEXT", *valid_profile.get_info_string("LanceSearchType")); ASSERT_NE(valid_profile.get_info_string("LanceFtsCoverageMode"), nullptr); EXPECT_EQ("STRICT", *valid_profile.get_info_string("LanceFtsCoverageMode")); + ASSERT_NE(valid_profile.get_info_string("LanceFtsQueryType"), nullptr); + EXPECT_EQ("MATCH", *valid_profile.get_info_string("LanceFtsQueryType")); + ASSERT_NE(valid_profile.get_info_string("LanceFtsMatchOperator"), nullptr); + EXPECT_EQ("OR", *valid_profile.get_info_string("LanceFtsMatchOperator")); + ASSERT_NE(valid_profile.get_info_string("LanceFtsMaxFuzzyDistance"), nullptr); + EXPECT_EQ("0", *valid_profile.get_info_string("LanceFtsMaxFuzzyDistance")); ASSERT_NE(valid_profile.get_info_string("LanceTopKPlusOffset"), nullptr); EXPECT_EQ("5", *valid_profile.get_info_string("LanceTopKPlusOffset")); @@ -411,6 +437,55 @@ TEST(LanceTableReaderFullTextSearchTest, ValidatesRequestAndScoreTypeBeforeDatas EXPECT_NE(score_status.to_string().find("must have Doris FLOAT type"), std::string::npos); } +TEST(LanceTableReaderFullTextSearchTest, ValidatesQuerySpecificParameters) { + const Columns columns { + projected_column("row_id", TYPE_BIGINT, false), + projected_column("_score", TYPE_FLOAT, true), + }; + TQueryGlobals query_globals; + RuntimeState state(query_globals); + + RuntimeProfile phrase_profile("lance_fts_phrase_request"); + auto phrase_params = make_phrase_search_params("lance search", 4, 0, 1); + LanceTableReader phrase_reader; + ASSERT_TRUE(init_reader(&phrase_reader, columns, &state, &phrase_profile, &phrase_params).ok()); + ASSERT_NE(phrase_profile.get_info_string("LanceFtsQueryType"), nullptr); + EXPECT_EQ("PHRASE", *phrase_profile.get_info_string("LanceFtsQueryType")); + ASSERT_NE(phrase_profile.get_info_string("LanceFtsPhraseSlop"), nullptr); + EXPECT_EQ("1", *phrase_profile.get_info_string("LanceFtsPhraseSlop")); + + RuntimeProfile fuzzy_profile("lance_fts_fuzzy_request"); + auto fuzzy_params = make_full_text_search_params( + "lance", 4, 0, TFtsCoverageMode::STRICT, std::nullopt, + TFtsMatchOperator::OR, 1); + LanceTableReader fuzzy_reader; + const auto fuzzy_status = + init_reader(&fuzzy_reader, columns, &state, &fuzzy_profile, &fuzzy_params); + EXPECT_FALSE(fuzzy_status.ok()); + EXPECT_NE(fuzzy_status.to_string().find("does not yet support max_fuzzy_distance=1"), + std::string::npos); + + RuntimeProfile match_slop_profile("lance_fts_match_with_slop"); + auto match_slop_params = make_full_text_search_params("lance", 4, 0); + match_slop_params.lance_scan_params.external_search_request.search_query.full_text_search + .__set_phrase_slop(1); + LanceTableReader match_slop_reader; + const auto match_slop_status = init_reader(&match_slop_reader, columns, &state, + &match_slop_profile, &match_slop_params); + EXPECT_FALSE(match_slop_status.ok()); + EXPECT_NE(match_slop_status.to_string().find("MATCH query cannot set phrase_slop"), + std::string::npos); + + RuntimeProfile negative_slop_profile("lance_fts_negative_phrase_slop"); + auto negative_slop_params = make_phrase_search_params("lance search", 4, 0, -1); + LanceTableReader negative_slop_reader; + const auto negative_slop_status = init_reader(&negative_slop_reader, columns, &state, + &negative_slop_profile, &negative_slop_params); + EXPECT_FALSE(negative_slop_status.ok()); + EXPECT_NE(negative_slop_status.to_string().find("phrase_slop must be non-negative"), + std::string::npos); +} + std::vector> read_full_text_search_rows(LanceTableReader* reader, Block* block) { std::vector> rows; @@ -490,6 +565,77 @@ TEST(LanceTableReaderFullTextSearchTest, SearchesIndexedSnapshotWithOptionalScor EXPECT_TRUE(row_id_reader.close().ok()); } +TEST(LanceTableReaderFullTextSearchTest, SupportsMatchAndPhraseQueries) { + const std::filesystem::path dataset_uri = + "./be/test/format_v2/table/lance/data/fts_indexed.lance"; + LanceFixtureInfo fixture; + ASSERT_TRUE(get_fixture_info(dataset_uri, &fixture).ok()); + auto range = make_full_text_search_range(dataset_uri, fixture, "body_fts"); + + const Columns columns { + projected_column("row_id", TYPE_BIGINT, false), + projected_column("_score", TYPE_FLOAT, true), + }; + TQueryGlobals query_globals; + RuntimeState state(query_globals); + + RuntimeProfile match_and_profile("lance_fts_match_and"); + auto match_and_params = make_full_text_search_params( + "lance storage", 10, 0, TFtsCoverageMode::STRICT, std::nullopt, + TFtsMatchOperator::AND); + LanceTableReader match_and_reader; + ASSERT_TRUE(init_reader(&match_and_reader, columns, &state, &match_and_profile, + &match_and_params) + .ok()); + ASSERT_TRUE(prepare_range(&match_and_reader, range).ok()); + Block match_and_block; + add_output_columns(&match_and_block, columns); + const auto match_and_rows = + read_full_text_search_rows(&match_and_reader, &match_and_block); + ASSERT_EQ(1U, match_and_rows.size()); + EXPECT_EQ(7, match_and_rows[0].first); + EXPECT_TRUE(match_and_reader.close().ok()); + + RuntimeProfile phrase_profile("lance_fts_phrase_exact"); + auto phrase_params = make_phrase_search_params("lance search", 10, 0, 0); + LanceTableReader phrase_reader; + ASSERT_TRUE(init_reader(&phrase_reader, columns, &state, &phrase_profile, &phrase_params).ok()); + ASSERT_TRUE(prepare_range(&phrase_reader, range).ok()); + Block phrase_block; + add_output_columns(&phrase_block, columns); + const auto phrase_rows = read_full_text_search_rows(&phrase_reader, &phrase_block); + std::vector phrase_row_ids; + phrase_row_ids.reserve(phrase_rows.size()); + for (const auto& [row_id, score] : phrase_rows) { + EXPECT_GT(score, 0.0F); + phrase_row_ids.emplace_back(row_id); + } + std::sort(phrase_row_ids.begin(), phrase_row_ids.end()); + EXPECT_EQ((std::vector {1, 2, 3}), phrase_row_ids); + EXPECT_TRUE(phrase_reader.close().ok()); + + RuntimeProfile phrase_slop_profile("lance_fts_phrase_slop"); + auto phrase_slop_params = make_phrase_search_params("lance engine", 10, 0, 1); + LanceTableReader phrase_slop_reader; + ASSERT_TRUE(init_reader(&phrase_slop_reader, columns, &state, &phrase_slop_profile, + &phrase_slop_params) + .ok()); + ASSERT_TRUE(prepare_range(&phrase_slop_reader, range).ok()); + Block phrase_slop_block; + add_output_columns(&phrase_slop_block, columns); + const auto phrase_slop_rows = + read_full_text_search_rows(&phrase_slop_reader, &phrase_slop_block); + std::vector phrase_slop_row_ids; + phrase_slop_row_ids.reserve(phrase_slop_rows.size()); + for (const auto& [row_id, score] : phrase_slop_rows) { + EXPECT_GT(score, 0.0F); + phrase_slop_row_ids.emplace_back(row_id); + } + std::sort(phrase_slop_row_ids.begin(), phrase_slop_row_ids.end()); + EXPECT_EQ((std::vector {1, 2, 3}), phrase_slop_row_ids); + EXPECT_TRUE(phrase_slop_reader.close().ok()); +} + TEST(LanceTableReaderVectorSearchTest, RejectsMalformedIndexSegmentUuid) { const std::filesystem::path dataset_uri = "./be/test/format_v2/table/lance/data/all_types.lance"; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java index 16a20a6176218a..626fe08870102a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LanceScanNode.java @@ -40,6 +40,7 @@ import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFtsQueryType; import org.apache.doris.thrift.TFullTextSearchParams; import org.apache.doris.thrift.TLanceFileDesc; import org.apache.doris.thrift.TLanceScanParams; @@ -554,6 +555,17 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { .append(fullText.getColumn()).append("\n"); result.append(prefix).append("lanceFtsCoverageMode=") .append(fullText.getCoverageMode()).append("\n"); + result.append(prefix).append("lanceFtsQueryType=") + .append(fullText.getQueryType()).append("\n"); + if (fullText.getQueryType() == TFtsQueryType.MATCH) { + result.append(prefix).append("lanceFtsMatchOperator=") + .append(fullText.getMatchOperator()).append("\n"); + result.append(prefix).append("lanceFtsMaxFuzzyDistance=") + .append(fullText.getMaxFuzzyDistance()).append("\n"); + } else { + result.append(prefix).append("lanceFtsPhraseSlop=") + .append(fullText.getPhraseSlop()).append("\n"); + } } result.append(prefix).append("lanceVersion=") .append(plannedMetadata.getVersion()).append("\n"); diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java index 034f199cf24bb4..3acf3adbd5f1d9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunction.java @@ -22,6 +22,8 @@ import org.apache.doris.thrift.TExternalSearchQuery; import org.apache.doris.thrift.TExternalSearchRequest; import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFtsMatchOperator; +import org.apache.doris.thrift.TFtsQueryType; import org.apache.doris.thrift.TFullTextSearchParams; import com.google.common.annotations.VisibleForTesting; @@ -40,8 +42,13 @@ public class FullTextSearchTableValuedFunction extends LanceExternalSearchTableV private static final String QUERY = "query"; private static final String COVERAGE_MODE = "coverage_mode"; + private static final String QUERY_TYPE = "query_type"; + private static final String OPERATOR = "operator"; + private static final String MAX_FUZZY_DISTANCE = "max_fuzzy_distance"; + private static final String SLOP = "slop"; private static final Set PROPERTIES = ImmutableSet.of( - TABLE, COLUMN, QUERY, TOP_K, OFFSET, FILTER, COVERAGE_MODE); + TABLE, COLUMN, QUERY, TOP_K, OFFSET, FILTER, COVERAGE_MODE, + QUERY_TYPE, OPERATOR, MAX_FUZZY_DISTANCE, SLOP); public FullTextSearchTableValuedFunction(Map properties) throws AnalysisException { @@ -61,13 +68,41 @@ private static PreparedSearch prepare(Map properties) throw new AnalysisException("'query' must not contain an embedded NUL byte"); } + TFtsQueryType queryType = parseQueryType(params.getOrDefault(QUERY_TYPE, "match")); TFullTextSearchParams fullTextParams = new TFullTextSearchParams() .setColumn(field.getName()) .setQuery(query) .setTopK(common.topK()) .setOffset(common.offset()) + .setQueryType(queryType) .setCoverageMode(parseCoverageMode( params.getOrDefault(COVERAGE_MODE, "strict"))); + if (queryType == TFtsQueryType.MATCH) { + if (params.containsKey(SLOP)) { + throw new AnalysisException("'slop' is only valid for query_type=phrase"); + } + int maxFuzzyDistance = (int) parseLong( + params.getOrDefault(MAX_FUZZY_DISTANCE, "0"), + MAX_FUZZY_DISTANCE, 0, Integer.MAX_VALUE); + if (maxFuzzyDistance != 0) { + throw new AnalysisException("'max_fuzzy_distance' must be 0 because the bundled " + + "Lance-C does not yet support prepared fuzzy matching"); + } + fullTextParams.setMatchOperator( + parseMatchOperator(params.getOrDefault(OPERATOR, "or"))) + .setMaxFuzzyDistance(maxFuzzyDistance); + } else { + if (params.containsKey(OPERATOR)) { + throw new AnalysisException("'operator' is only valid for query_type=match"); + } + if (params.containsKey(MAX_FUZZY_DISTANCE)) { + throw new AnalysisException( + "'max_fuzzy_distance' is only valid for query_type=match"); + } + int slop = (int) parseLong( + params.getOrDefault(SLOP, "0"), SLOP, 0, Integer.MAX_VALUE); + fullTextParams.setPhraseSlop(slop); + } TExternalSearchRequest searchRequest = new TExternalSearchRequest() .setSchemaVersion(1) .setSearchQuery(TExternalSearchQuery.full_text_search(fullTextParams)); @@ -113,4 +148,30 @@ static TFtsCoverageMode parseCoverageMode(String value) throws AnalysisException + "': expected strict or index_only"); } } + + @VisibleForTesting + static TFtsQueryType parseQueryType(String value) throws AnalysisException { + switch (value.trim().toLowerCase(Locale.ROOT)) { + case "match": + return TFtsQueryType.MATCH; + case "phrase": + return TFtsQueryType.PHRASE; + default: + throw new AnalysisException("Unsupported FTS query_type '" + value + + "': expected match or phrase"); + } + } + + @VisibleForTesting + static TFtsMatchOperator parseMatchOperator(String value) throws AnalysisException { + switch (value.trim().toLowerCase(Locale.ROOT)) { + case "or": + return TFtsMatchOperator.OR; + case "and": + return TFtsMatchOperator.AND; + default: + throw new AnalysisException("Unsupported FTS operator '" + value + + "': expected 'or' or 'and'"); + } + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java index c71c839fbc8bee..ddffce15bdf293 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/source/LanceScanNodeTest.java @@ -31,6 +31,8 @@ import org.apache.doris.thrift.TExternalSearchRequest; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFtsMatchOperator; +import org.apache.doris.thrift.TFtsQueryType; import org.apache.doris.thrift.TFullTextSearchParams; import org.apache.doris.thrift.TVectorMetric; import org.apache.doris.thrift.TVectorSearchOptions; @@ -304,6 +306,36 @@ public void testFullTextSearchUsesOneSplitPerCommittedIndexSegment() throws Exce splitRequest.getSearchQuery().getFullTextSearch().getOffset()); Assert.assertEquals(5, request.getSearchQuery().getFullTextSearch().getTopK()); Assert.assertEquals(2, request.getSearchQuery().getFullTextSearch().getOffset()); + Assert.assertEquals(TFtsQueryType.MATCH, + splitRequest.getSearchQuery().getFullTextSearch().getQueryType()); + Assert.assertEquals(TFtsMatchOperator.OR, + splitRequest.getSearchQuery().getFullTextSearch().getMatchOperator()); + Assert.assertEquals(0, + splitRequest.getSearchQuery().getFullTextSearch().getMaxFuzzyDistance()); + } + + @Test + public void testFullTextSplitRequestPreservesPhraseQuery() { + TExternalSearchRequest logicalRequest = phraseSearchRequest(5, 2, 1); + LanceScanNode node = LanceScanNode.forExternalSearch( + new PlanNodeId(0), new TupleDescriptor(new TupleId(0)), null, + null, -1, logicalRequest, new SessionVariable()); + + TExternalSearchRequest splitRequest = node.createSplitSearchRequest(); + + TFullTextSearchParams splitFullText = + splitRequest.getSearchQuery().getFullTextSearch(); + Assert.assertEquals(7, splitFullText.getTopK()); + Assert.assertEquals(0, splitFullText.getOffset()); + Assert.assertEquals(TFtsQueryType.PHRASE, splitFullText.getQueryType()); + Assert.assertEquals(1, splitFullText.getPhraseSlop()); + Assert.assertFalse(splitFullText.isSetMatchOperator()); + Assert.assertFalse(splitFullText.isSetMaxFuzzyDistance()); + + TFullTextSearchParams logicalFullText = + logicalRequest.getSearchQuery().getFullTextSearch(); + Assert.assertEquals(5, logicalFullText.getTopK()); + Assert.assertEquals(2, logicalFullText.getOffset()); } @Test @@ -487,7 +519,23 @@ private static TExternalSearchRequest fullTextSearchRequest( .setQuery("lance") .setTopK(topK) .setOffset(offset) - .setCoverageMode(coverageMode); + .setCoverageMode(coverageMode) + .setQueryType(TFtsQueryType.MATCH) + .setMatchOperator(TFtsMatchOperator.OR) + .setMaxFuzzyDistance(0); + return new TExternalSearchRequest() + .setSearchQuery(TExternalSearchQuery.full_text_search(fullText)); + } + + private static TExternalSearchRequest phraseSearchRequest(long topK, long offset, int slop) { + TFullTextSearchParams fullText = new TFullTextSearchParams() + .setColumn("body") + .setQuery("lance search") + .setTopK(topK) + .setOffset(offset) + .setCoverageMode(TFtsCoverageMode.STRICT) + .setQueryType(TFtsQueryType.PHRASE) + .setPhraseSlop(slop); return new TExternalSearchRequest() .setSearchQuery(TExternalSearchQuery.full_text_search(fullText)); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java index f489a3ede2db6d..10ab0f241100d3 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/FullTextSearchTableValuedFunctionTest.java @@ -22,6 +22,8 @@ import org.apache.doris.common.AnalysisException; import org.apache.doris.datasource.lance.LanceTableMetadata; import org.apache.doris.thrift.TFtsCoverageMode; +import org.apache.doris.thrift.TFtsMatchOperator; +import org.apache.doris.thrift.TFtsQueryType; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; @@ -48,6 +50,26 @@ public void testParseCoverageMode() throws Exception { Assert.assertTrue(invalid.getMessage().contains("strict or index_only")); } + @Test + public void testParseQueryTypeAndMatchOperator() throws Exception { + Assert.assertEquals(TFtsQueryType.MATCH, + FullTextSearchTableValuedFunction.parseQueryType(" MATCH ")); + Assert.assertEquals(TFtsQueryType.PHRASE, + FullTextSearchTableValuedFunction.parseQueryType("phrase")); + Assert.assertEquals(TFtsMatchOperator.OR, + FullTextSearchTableValuedFunction.parseMatchOperator(" OR ")); + Assert.assertEquals(TFtsMatchOperator.AND, + FullTextSearchTableValuedFunction.parseMatchOperator("and")); + + AnalysisException invalidType = Assert.assertThrows(AnalysisException.class, + () -> FullTextSearchTableValuedFunction.parseQueryType("boolean")); + Assert.assertTrue(invalidType.getMessage().contains("match or phrase")); + + AnalysisException invalidOperator = Assert.assertThrows(AnalysisException.class, + () -> FullTextSearchTableValuedFunction.parseMatchOperator("xor")); + Assert.assertTrue(invalidOperator.getMessage().contains("'or' or 'and'")); + } + @Test public void testResolveStringColumnCaseInsensitively() throws Exception { Field rowId = Field.notNullable("row_id", new ArrowType.Int(64, true)); diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift index 6433d8083b0dd3..5ae5251f54f849 100644 --- a/gensrc/thrift/PlanNodes.thrift +++ b/gensrc/thrift/PlanNodes.thrift @@ -483,8 +483,18 @@ enum TFtsCoverageMode { INDEX_ONLY } -// Logical parameters for one full-text query. `query` initially carries the backend query string; -// richer structured query forms can be added as new fields without changing this basic contract. +enum TFtsQueryType { + MATCH, + PHRASE +} + +enum TFtsMatchOperator { + OR, + AND +} + +// Logical parameters for one full-text query. MATCH combines analyzed terms with match_operator; +// PHRASE searches the analyzed terms in order and permits phrase_slop intervening positions. struct TFullTextSearchParams { 1: optional string column 2: optional string query @@ -493,10 +503,23 @@ struct TFullTextSearchParams { // STRICT requires the selected FTS index to cover the complete pinned snapshot. INDEX_ONLY // searches and scores only fragments covered by committed FTS index segments. 5: optional TFtsCoverageMode coverage_mode - // Opaque, versioned global BM25 statistics prepared by Lance for this exact snapshot and - // query. Unset while BE scanners prepare statistics locally; future FE versions may populate - // this field once the bundled lance-c exposes the corresponding consumer API. + // Reserved for distributed FTS. Every BE may search a different index-segment subset, so + // statistics prepared independently on each BE would produce BM25 scores that are not + // comparable during the final TopK merge. The payload must be prepared once for the exact + // snapshot, logical FTS index, segment set, and query (including its final fuzzy vocabulary), + // then validated and installed unchanged on every BE scanner. + // + // Doris currently leaves this field unset and BE rejects a set value because Lance/lance-c + // does not yet provide the complete producer and consumer contract. Track the upstream work + // at https://github.com/lance-format/lance/issues/8937. 6: optional binary global_statistics + 7: optional TFtsQueryType query_type + // MATCH-only parameters. max_fuzzy_distance is reserved as zero until the bundled Lance-C + // supports one canonical fuzzy vocabulary across all prepared index segments. + 8: optional TFtsMatchOperator match_operator + 9: optional i32 max_fuzzy_distance + // PHRASE-only parameter. Zero requires an exact phrase. + 10: optional i32 phrase_slop } enum TSearchFilterFormat { diff --git a/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out b/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out index 97cef25707580a..31f0f62d9034e1 100644 --- a/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out +++ b/regression-test/data/external_table_p0/lance/test_lance_full_text_search.out @@ -5,6 +5,25 @@ 1 0.74574697 7 0.6000379 +-- !fts_match_or -- +1 +2 +3 +7 + +-- !fts_match_and -- +7 + +-- !fts_phrase_exact -- +1 +2 +3 + +-- !fts_phrase_slop -- +1 +2 +3 + -- !fts_offset -- 2 1 @@ -15,4 +34,3 @@ -- !fts_index_only -- 101 102 - diff --git a/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy b/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy index 6031c666464060..a2f9901cb42637 100644 --- a/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy +++ b/regression-test/suites/external_table_p0/lance/test_lance_full_text_search.groovy @@ -64,6 +64,8 @@ suite("test_lance_full_text_search", "p0,external") { contains "externalSearchType=FULL_TEXT" contains "lanceFullTextColumn=body" contains "lanceFtsCoverageMode=STRICT" + contains "lanceFtsQueryType=MATCH" + contains "lanceFtsMatchOperator=OR" contains "lanceSearchUnindexedFragments=0" contains "lanceSearchIndexSegments=" } @@ -74,6 +76,45 @@ suite("test_lance_full_text_search", "p0,external") { ORDER BY _score DESC, row_id """ + // MATCH defaults to OR, while AND requires every analyzed query term to be present. + qt_fts_match_or """ + SELECT row_id + FROM ${search(fullTable, "lance storage", "10", "0", "strict", + ', "query_type"="match", "operator"="or"')} + ORDER BY row_id + """ + qt_fts_match_and """ + SELECT row_id + FROM ${search(fullTable, "lance storage", "10", "0", "strict", + ', "query_type"="match", "operator"="and"')} + ORDER BY row_id + """ + + // This fixture stores token positions. Exact Phrase matches adjacent terms, while slop=1 + // permits the intervening "search" token in "lance search engine". + qt_fts_phrase_exact """ + SELECT row_id + FROM ${search(fullTable, "lance search", "10", "0", "strict", + ', "query_type"="phrase", "slop"="0"')} + ORDER BY row_id + """ + qt_fts_phrase_slop """ + SELECT row_id + FROM ${search(fullTable, "lance engine", "10", "0", "strict", + ', "query_type"="phrase", "slop"="1"')} + ORDER BY row_id + """ + + // The wire contract already reserves fuzzy distance, but the bundled Lance revision does + // not yet provide one canonical fuzzy vocabulary across prepared index segments. + test { + sql """SELECT row_id + FROM ${search(fullTable, "lance", "10", "0", "strict", + ', "max_fuzzy_distance"="1"')} + ORDER BY row_id""" + exception "does not yet support prepared fuzzy matching" + } + // OFFSET belongs to the snapshot-wide TopN. It must not be applied independently by // every physical FTS index split. qt_fts_offset """ diff --git a/thirdparty/download-thirdparty.sh b/thirdparty/download-thirdparty.sh index 3d05caa056bc7d..ec251c33b50463 100755 --- a/thirdparty/download-thirdparty.sh +++ b/thirdparty/download-thirdparty.sh @@ -780,6 +780,7 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then cd "${TP_SOURCE_DIR}/${LANCE_C_SOURCE}" if [[ ! -f "${PATCHED_MARK}" ]]; then patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.8-pr-69.patch" + patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.8-pr-74.patch" touch "${PATCHED_MARK}" fi cd - diff --git a/thirdparty/patches/lance-c-0.1.8-pr-74.patch b/thirdparty/patches/lance-c-0.1.8-pr-74.patch new file mode 100644 index 00000000000000..24c6d33457d84c --- /dev/null +++ b/thirdparty/patches/lance-c-0.1.8-pr-74.patch @@ -0,0 +1,1159 @@ +From b07f970bf2cf3f983cc6043bc72a0fe444c0fd4c Mon Sep 17 00:00:00 2001 +From: zhangstar333 +Date: Thu, 3 Sep 2026 17:16:34 +0800 +Subject: [PATCH 1/2] fts + +--- + include/lance/lance.h | 74 ++++++++--- + include/lance/lance.hpp | 44 +++++-- + src/fts_query.rs | 235 +++++++++++++++++++++++++++++------ + src/scanner.rs | 72 ++++++++--- + tests/c_api_test.rs | 264 +++++++++++++++++++++++++++++++++++++--- + 5 files changed, 595 insertions(+), 94 deletions(-) + +diff --git a/include/lance/lance.h b/include/lance/lance.h +index 3bf291f..0c76edc 100644 +--- a/include/lance/lance.h ++++ b/include/lance/lance.h +@@ -1695,39 +1695,85 @@ typedef enum { + LANCE_FTS_COVERAGE_INDEX_ONLY = 1, + } LanceFtsCoverageMode; + ++/** How the analyzed terms of one Match query are combined. */ ++typedef enum { ++ /** At least one analyzed term must match. */ ++ LANCE_FTS_MATCH_OPERATOR_OR = 0, ++ /** Every analyzed term must match. */ ++ LANCE_FTS_MATCH_OPERATOR_AND = 1, ++} LanceFtsMatchOperator; ++ ++/** ++ * Prepare an OR Match query context for one column. ++ * ++ * @deprecated Use lance_dataset_prepare_fts_match_query() to select the Match ++ * operator explicitly. This compatibility API is equivalent to ++ * LANCE_FTS_MATCH_OPERATOR_OR. ++ */ ++LanceFtsQueryContext* lance_dataset_prepare_fts_query( ++ const LanceDataset* dataset, ++ const char* column, ++ const char* query, ++ uint32_t max_fuzzy_distance, ++ int32_t coverage_mode ++); ++ + /** +- * Prepare an immutable, process-local FTS query context for one column. ++ * Prepare an immutable, process-local Match query context for one column. + * + * Preparation pins the dataset handle's current snapshot, enumerates all + * committed FTS segments for `column`, checks fragment coverage, opens those +- * segments, and computes one query-specific global BM25 scorer across their +- * indexed documents. The context can then be shared by any number of scanners +- * created from the exact same process-local dataset snapshot. It has no +- * serialization or cross-process transport format. Reopening the same URI and +- * manifest version creates a different identity and cannot reuse the context, +- * because storage options and object-store endpoints may differ. ++ * segments, and prepares one global BM25 scorer across their indexed ++ * documents. `match_operator` supports both AND and OR. ++ * ++ * The context can be shared by scanners created from the exact same ++ * process-local dataset snapshot. It has no serialization or cross-process ++ * transport format. Reopening the same URI and manifest version creates a ++ * different identity and cannot reuse the context because storage options and ++ * object-store endpoints may differ. + * + * In LANCE_FTS_COVERAGE_INDEX_ONLY mode, unindexed fragments are allowed and + * excluded from both the scorer corpus and query results. In STRICT mode any + * unindexed fragment makes this call fail. + * +- * Prepared contexts currently support exact Match queries only. +- * `max_fuzzy_distance` must be zero because fuzzy execution requires its +- * canonical expanded vocabulary to be prepared together with the scorer. +- * This restriction does not apply to lance_scanner_full_text_search(). +- * +- * @param max_fuzzy_distance Must be zero for prepared query contexts. ++ * @param match_operator Fixed-width LanceFtsMatchOperator discriminant. ++ * @param max_fuzzy_distance Reserved for prepared fuzzy matching and currently ++ * must be 0. The parameter is retained so enabling ++ * canonical cross-segment fuzzy vocabulary injection ++ * later does not require another C ABI change. + * @param coverage_mode Fixed-width LanceFtsCoverageMode discriminant. + * @return Context handle on success, or NULL on error. + */ +-LanceFtsQueryContext* lance_dataset_prepare_fts_query( ++LanceFtsQueryContext* lance_dataset_prepare_fts_match_query( + const LanceDataset* dataset, + const char* column, + const char* query, ++ int32_t match_operator, + uint32_t max_fuzzy_distance, + int32_t coverage_mode + ); + ++/** ++ * Prepare an immutable, process-local Phrase query context for one column. ++ * ++ * The selected FTS index must store token positions. `slop == 0` requires an ++ * exact phrase; a positive value permits that many intervening positions. ++ * Dataset identity, coverage, sharing, and segment-scoped execution follow the ++ * same contract as lance_dataset_prepare_fts_match_query(). ++ * ++ * @param slop Maximum number of intervening token positions permitted between ++ * adjacent phrase terms. ++ * @param coverage_mode Fixed-width LanceFtsCoverageMode discriminant. ++ * @return Context handle on success, or NULL on error. ++ */ ++LanceFtsQueryContext* lance_dataset_prepare_fts_phrase_query( ++ const LanceDataset* dataset, ++ const char* column, ++ const char* query, ++ uint32_t slop, ++ int32_t coverage_mode ++); ++ + /** + * Close a context handle. NULL-safe. Scanners that already attached this + * context retain shared ownership and remain valid. +diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp +index 6cf245f..973216a 100644 +--- a/include/lance/lance.hpp ++++ b/include/lance/lance.hpp +@@ -128,6 +128,11 @@ enum class FtsCoverageMode : int32_t { + IndexOnly = LANCE_FTS_COVERAGE_INDEX_ONLY, + }; + ++enum class FtsMatchOperator : int32_t { ++ Or = LANCE_FTS_MATCH_OPERATOR_OR, ++ And = LANCE_FTS_MATCH_OPERATOR_AND, ++}; ++ + /// Tunable parameters for Dataset::write. Numeric fields default-out via 0; + /// `data_storage_version` defaults out via `std::nullopt`. + /// +@@ -764,18 +769,43 @@ class Dataset { + /// Create a Scanner builder for this dataset. + Scanner scan() const; + +- /// Prepare a query-specific global BM25 scorer over the committed FTS +- /// segments of this pinned snapshot. IndexOnly permits unindexed fragments; +- /// Strict rejects them. Prepared contexts currently require +- /// `max_fuzzy_distance == 0`. The context can only be attached to scanners +- /// created from this exact process-local dataset snapshot. ++ /// Compatibility wrapper for an OR Match query. ++ [[deprecated("Use prepare_fts_match_query() to select the Match operator")]] + FtsQueryContext prepare_fts_query( + const std::string& column, + const std::string& query, + uint32_t max_fuzzy_distance = 0, + FtsCoverageMode coverage_mode = FtsCoverageMode::Strict) const { +- auto* context = lance_dataset_prepare_fts_query( +- handle_.get(), column.c_str(), query.c_str(), max_fuzzy_distance, ++ return prepare_fts_match_query(column, query, FtsMatchOperator::Or, ++ max_fuzzy_distance, coverage_mode); ++ } ++ ++ /// Prepare a Match query with a global BM25 scorer. AND and OR are ++ /// supported. `max_fuzzy_distance` is reserved and currently must be zero; ++ /// keeping it here avoids another API change when canonical cross-segment ++ /// fuzzy vocabulary injection becomes available. ++ FtsQueryContext prepare_fts_match_query( ++ const std::string& column, ++ const std::string& query, ++ FtsMatchOperator match_operator = FtsMatchOperator::Or, ++ uint32_t max_fuzzy_distance = 0, ++ FtsCoverageMode coverage_mode = FtsCoverageMode::Strict) const { ++ auto* context = lance_dataset_prepare_fts_match_query( ++ handle_.get(), column.c_str(), query.c_str(), ++ static_cast(match_operator), max_fuzzy_distance, ++ static_cast(coverage_mode)); ++ if (!context) check_error(); ++ return FtsQueryContext(context); ++ } ++ ++ /// Prepare a Phrase query. Its FTS index must store token positions. ++ FtsQueryContext prepare_fts_phrase_query( ++ const std::string& column, ++ const std::string& query, ++ uint32_t slop = 0, ++ FtsCoverageMode coverage_mode = FtsCoverageMode::Strict) const { ++ auto* context = lance_dataset_prepare_fts_phrase_query( ++ handle_.get(), column.c_str(), query.c_str(), slop, + static_cast(coverage_mode)); + if (!context) check_error(); + return FtsQueryContext(context); +diff --git a/src/fts_query.rs b/src/fts_query.rs +index cd194c7..3bf7f3e 100644 +--- a/src/fts_query.rs ++++ b/src/fts_query.rs +@@ -14,7 +14,9 @@ use lance_core::{Error, Result}; + use lance_index::IndexCriteria; + use lance_index::metrics::NoOpMetricsCollector; + use lance_index::scalar::FullTextSearchQuery; +-use lance_index::scalar::inverted::query::{FtsQuery, collect_query_tokens}; ++use lance_index::scalar::inverted::query::{ ++ FtsQuery, MatchQuery, Operator, PhraseQuery, collect_query_tokens, ++}; + use lance_index::scalar::inverted::{InvertedIndex, MemBM25Scorer, build_global_bm25_scorer}; + use lance_table::format::IndexMetadata; + use uuid::Uuid; +@@ -48,12 +50,53 @@ impl TryFrom for LanceFtsCoverageMode { + } + } + ++/// Operator used to combine the analyzed terms of a Match query. ++#[repr(i32)] ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub enum LanceFtsMatchOperator { ++ /// At least one analyzed term must match. ++ Or = 0, ++ /// Every analyzed term must match. ++ And = 1, ++} ++ ++impl TryFrom for LanceFtsMatchOperator { ++ type Error = Error; ++ ++ fn try_from(value: i32) -> Result { ++ match value { ++ 0 => Ok(Self::Or), ++ 1 => Ok(Self::And), ++ _ => Err(Error::invalid_input(format!( ++ "invalid match_operator {value}; expected 0 (OR) or 1 (AND)" ++ ))), ++ } ++ } ++} ++ ++impl From for Operator { ++ fn from(value: LanceFtsMatchOperator) -> Self { ++ match value { ++ LanceFtsMatchOperator::Or => Self::Or, ++ LanceFtsMatchOperator::And => Self::And, ++ } ++ } ++} ++ ++/// Query-specific state that must be shared by every segment-scoped scan. ++pub(crate) enum PreparedFtsQuery { ++ /// Exact Match queries share one corpus-wide scorer. ++ Match(Arc), ++ /// Phrase does not expand terms, so a shared global scorer is sufficient. ++ Phrase(Arc), ++} ++ + /// Rust-owned immutable state behind [`LanceFtsQueryContext`]. + pub(crate) struct FtsQueryContextInner { + pub(crate) dataset: Arc, + pub(crate) query: FullTextSearchQuery, + pub(crate) segments: Vec, +- pub(crate) scorer: Arc, ++ pub(crate) prepared: PreparedFtsQuery, + } + + impl FtsQueryContextInner { +@@ -87,7 +130,7 @@ fn invalid_input(message: impl Into) -> Error { + async fn prepare_fts_query_context( + dataset: Arc, + column: String, +- query_text: String, ++ query: FullTextSearchQuery, + coverage_mode: LanceFtsCoverageMode, + ) -> Result { + let logical_index = dataset +@@ -193,34 +236,78 @@ async fn prepare_fts_query_context( + ))); + } + +- let query = FullTextSearchQuery::new(query_text).with_column(column.clone())?; +- let match_query = match &query.query { +- FtsQuery::Match(query) => query, ++ let prepared = match &query.query { ++ FtsQuery::Match(match_query) => { ++ let mut tokenizer = indices[0].tokenizer(); ++ let query_tokens = collect_query_tokens(&match_query.terms, &mut tokenizer); ++ let params = query ++ .params() ++ .with_fuzziness(match_query.fuzziness) ++ .with_max_expansions(match_query.max_expansions) ++ .with_prefix_length(match_query.prefix_length); ++ PreparedFtsQuery::Match(Arc::new( ++ build_global_bm25_scorer(&indices, &query_tokens, ¶ms).await?, ++ )) ++ } ++ FtsQuery::Phrase(phrase_query) => { ++ if !expected_params.has_positions() { ++ return Err(invalid_input(format!( ++ "FTS index '{}' for column '{column}' does not store token positions required by Phrase queries; recreate the index with positions enabled", ++ logical_index.name ++ ))); ++ } ++ let mut tokenizer = indices[0].tokenizer(); ++ let query_tokens = collect_query_tokens(&phrase_query.terms, &mut tokenizer); ++ let params = query.params().with_phrase_slop(Some(phrase_query.slop)); ++ PreparedFtsQuery::Phrase(Arc::new( ++ build_global_bm25_scorer(&indices, &query_tokens, ¶ms).await?, ++ )) ++ } + _ => { + return Err(Error::internal( +- "prepared FTS query unexpectedly produced a non-Match query".to_string(), ++ "prepared FTS query must be a single-column Match or Phrase query".to_string(), + )); + } + }; +- let mut tokenizer = indices[0].tokenizer(); +- let query_tokens = collect_query_tokens(&match_query.terms, &mut tokenizer); +- let params = query +- .params() +- .with_fuzziness(match_query.fuzziness) +- .with_max_expansions(match_query.max_expansions) +- .with_prefix_length(match_query.prefix_length); +- let scorer = Arc::new(build_global_bm25_scorer(&indices, &query_tokens, ¶ms).await?); + + Ok(FtsQueryContextInner { + dataset, + query, + segments, +- scorer, ++ prepared, + }) + } + +-/// Prepare a process-local global BM25 scorer and the committed segment list +-/// for one single-column Match query against the dataset's pinned snapshot. ++unsafe fn parse_query_inputs( ++ dataset: *const LanceDataset, ++ column: *const c_char, ++ query: *const c_char, ++ coverage_mode: i32, ++) -> Result<(Arc, String, String, LanceFtsCoverageMode)> { ++ if dataset.is_null() || column.is_null() || query.is_null() { ++ return Err(invalid_input("dataset, column, and query must not be NULL")); ++ } ++ let column = unsafe { helpers::parse_c_string(column)? } ++ .filter(|value| !value.is_empty()) ++ .ok_or_else(|| invalid_input("column must not be empty"))? ++ .to_string(); ++ let query = unsafe { helpers::parse_c_string(query)? } ++ .filter(|value| !value.is_empty()) ++ .ok_or_else(|| invalid_input("query must not be empty"))? ++ .to_string(); ++ let coverage_mode = LanceFtsCoverageMode::try_from(coverage_mode)?; ++ let snapshot = unsafe { &*dataset }.snapshot(); ++ Ok((snapshot, column, query, coverage_mode)) ++} ++ ++fn into_context(inner: FtsQueryContextInner) -> *mut LanceFtsQueryContext { ++ Box::into_raw(Box::new(LanceFtsQueryContext { ++ inner: Arc::new(inner), ++ })) ++} ++ ++/// Compatibility API for an OR Match query. ++#[deprecated(note = "use lance_dataset_prepare_fts_match_query to select the Match operator")] + #[unsafe(no_mangle)] + pub unsafe extern "C" fn lance_dataset_prepare_fts_query( + dataset: *const LanceDataset, +@@ -231,46 +318,120 @@ pub unsafe extern "C" fn lance_dataset_prepare_fts_query( + ) -> *mut LanceFtsQueryContext { + ffi_try!( + unsafe { +- prepare_fts_query_inner(dataset, column, query, max_fuzzy_distance, coverage_mode) ++ prepare_fts_match_query_inner( ++ dataset, ++ column, ++ query, ++ LanceFtsMatchOperator::Or as i32, ++ max_fuzzy_distance, ++ coverage_mode, ++ ) ++ }, ++ null ++ ) ++} ++ ++/// Prepare a process-local Match query context. AND and OR are supported. ++/// `max_fuzzy_distance` is retained for the future prepared-fuzzy path but ++/// must be zero with the currently pinned Lance revision. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_dataset_prepare_fts_match_query( ++ dataset: *const LanceDataset, ++ column: *const c_char, ++ query: *const c_char, ++ match_operator: i32, ++ max_fuzzy_distance: u32, ++ coverage_mode: i32, ++) -> *mut LanceFtsQueryContext { ++ ffi_try!( ++ unsafe { ++ prepare_fts_match_query_inner( ++ dataset, ++ column, ++ query, ++ match_operator, ++ max_fuzzy_distance, ++ coverage_mode, ++ ) + }, + null + ) + } + +-unsafe fn prepare_fts_query_inner( ++unsafe fn prepare_fts_match_query_inner( + dataset: *const LanceDataset, + column: *const c_char, + query: *const c_char, ++ match_operator: i32, + max_fuzzy_distance: u32, + coverage_mode: i32, + ) -> Result<*mut LanceFtsQueryContext> { +- if dataset.is_null() || column.is_null() || query.is_null() { +- return Err(invalid_input("dataset, column, and query must not be NULL")); +- } +- let column = unsafe { helpers::parse_c_string(column)? } +- .filter(|value| !value.is_empty()) +- .ok_or_else(|| invalid_input("column must not be empty"))? +- .to_string(); +- let query = unsafe { helpers::parse_c_string(query)? } +- .filter(|value| !value.is_empty()) +- .ok_or_else(|| invalid_input("query must not be empty"))? +- .to_string(); +- let coverage_mode = LanceFtsCoverageMode::try_from(coverage_mode)?; ++ let (snapshot, column, query_text, coverage_mode) = ++ unsafe { parse_query_inputs(dataset, column, query, coverage_mode)? }; ++ let operator: Operator = LanceFtsMatchOperator::try_from(match_operator)?.into(); ++ // The parameter remains in the public API so callers do not need another ++ // ABI change when Lance-C moves to a Lance revision that can inject the ++ // same canonical fuzzy vocabulary into every segment-scoped scan. The ++ // pinned Lance revision can share only the scorer, so accepting fuzzy here ++ // would allow different segments to choose different capped expansions. + if max_fuzzy_distance != 0 { + return Err(invalid_input(format!( +- "max_fuzzy_distance must be 0 for prepared FTS query contexts, got {max_fuzzy_distance}; fuzzy queries require a canonical prepared BM25 vocabulary" ++ "max_fuzzy_distance must be 0 for prepared FTS with the pinned Lance revision, got {max_fuzzy_distance}; the parameter is reserved until canonical fuzzy vocabulary injection is available" + ))); + } +- let snapshot = unsafe { &*dataset }.snapshot(); ++ let query = FullTextSearchQuery::new_query( ++ MatchQuery::new(query_text) ++ .with_column(Some(column.clone())) ++ .with_operator(operator) ++ .with_fuzziness(Some(0)) ++ .into(), ++ ); + let inner = block_on(prepare_fts_query_context( + snapshot, + column, + query, + coverage_mode, + ))?; +- Ok(Box::into_raw(Box::new(LanceFtsQueryContext { +- inner: Arc::new(inner), +- }))) ++ Ok(into_context(inner)) ++} ++ ++/// Prepare a process-local Phrase query context. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_dataset_prepare_fts_phrase_query( ++ dataset: *const LanceDataset, ++ column: *const c_char, ++ query: *const c_char, ++ slop: u32, ++ coverage_mode: i32, ++) -> *mut LanceFtsQueryContext { ++ ffi_try!( ++ unsafe { prepare_fts_phrase_query_inner(dataset, column, query, slop, coverage_mode) }, ++ null ++ ) ++} ++ ++unsafe fn prepare_fts_phrase_query_inner( ++ dataset: *const LanceDataset, ++ column: *const c_char, ++ query: *const c_char, ++ slop: u32, ++ coverage_mode: i32, ++) -> Result<*mut LanceFtsQueryContext> { ++ let (snapshot, column, query_text, coverage_mode) = ++ unsafe { parse_query_inputs(dataset, column, query, coverage_mode)? }; ++ let query = FullTextSearchQuery::new_query( ++ PhraseQuery::new(query_text) ++ .with_column(Some(column.clone())) ++ .with_slop(slop) ++ .into(), ++ ); ++ let inner = block_on(prepare_fts_query_context( ++ snapshot, ++ column, ++ query, ++ coverage_mode, ++ ))?; ++ Ok(into_context(inner)) + } + + /// Close a context handle. NULL-safe. Scanners that already attached the +diff --git a/src/scanner.rs b/src/scanner.rs +index 0c29b17..cbf1b13 100644 +--- a/src/scanner.rs ++++ b/src/scanner.rs +@@ -18,7 +18,7 @@ use lance::Dataset; + use lance::dataset::scanner::{ + DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts, + }; +-use lance::io::exec::fts::{FlatMatchQueryExec, MatchQueryExec}; ++use lance::io::exec::fts::{FlatMatchQueryExec, MatchQueryExec, PhraseQueryExec}; + use lance_core::Result; + use lance_index::scalar::FullTextSearchQuery; + use lance_io::stream::RecordBatchStream; +@@ -33,7 +33,8 @@ use crate::error::{ + panic_payload_message, set_lance_error, set_last_error, swallow_unwind, + }; + use crate::fts_query::{ +- FtsQueryContextInner, LanceFtsQueryContext, clone_context, parse_segment_uuids, ++ FtsQueryContextInner, LanceFtsQueryContext, PreparedFtsQuery, clone_context, ++ parse_segment_uuids, + }; + use crate::helpers; + use crate::runtime::{RT, block_on}; +@@ -328,17 +329,17 @@ impl PreparedScanner { + let (plan, rewritten) = rewrite_prepared_fts_plan( + plan, + &distributed_fts.segments, +- &distributed_fts.context.scorer, ++ &distributed_fts.context.prepared, + selected_segments_have_current_fragments, + )?; +- if rewritten.match_query_execs > 1 ++ if rewritten.indexed_query_execs > 1 + || rewritten.flat_match_query_execs > 1 +- || rewritten.match_query_execs + rewritten.flat_match_query_execs == 0 +- || (selected_segments_have_current_fragments && rewritten.match_query_execs != 1) ++ || rewritten.indexed_query_execs + rewritten.flat_match_query_execs == 0 ++ || (selected_segments_have_current_fragments && rewritten.indexed_query_execs != 1) + { + return Err(lance_core::Error::internal(format!( +- "unexpected prepared FTS plan for selected segments with current fragment coverage {selected_segments_have_current_fragments}: rewrote {} MatchQueryExec node(s) and removed {} FlatMatchQueryExec node(s)", +- rewritten.match_query_execs, rewritten.flat_match_query_execs ++ "unexpected prepared FTS plan for selected segments with current fragment coverage {selected_segments_have_current_fragments}: rewrote {} indexed FTS query node(s) and removed {} FlatMatchQueryExec node(s)", ++ rewritten.indexed_query_execs, rewritten.flat_match_query_execs + ))); + } + let stream = lance_datafusion::exec::execute_plan( +@@ -420,14 +421,14 @@ fn segments_have_current_fragments( + + #[derive(Default)] + struct PreparedFtsPlanRewriteCounts { +- match_query_execs: usize, ++ indexed_query_execs: usize, + flat_match_query_execs: usize, + } + + fn rewrite_prepared_fts_plan( + plan: Arc, + segments: &[IndexMetadata], +- scorer: &Arc, ++ prepared: &PreparedFtsQuery, + selected_segments_have_current_fragments: bool, + ) -> Result<(Arc, PreparedFtsPlanRewriteCounts)> { + // Lance's ordinary FTS planner adds a flat-search branch for fragments not +@@ -438,7 +439,7 @@ fn rewrite_prepared_fts_plan( + return Ok(( + Arc::new(EmptyExec::new(plan.schema())), + PreparedFtsPlanRewriteCounts { +- match_query_execs: 0, ++ indexed_query_execs: 0, + flat_match_query_execs: 1, + }, + )); +@@ -454,11 +455,11 @@ fn rewrite_prepared_fts_plan( + let (new_child, child_rewritten) = rewrite_prepared_fts_plan( + Arc::clone(child), + segments, +- scorer, ++ prepared, + selected_segments_have_current_fragments, + )?; + new_children.push(new_child); +- rewritten.match_query_execs += child_rewritten.match_query_execs; ++ rewritten.indexed_query_execs += child_rewritten.indexed_query_execs; + rewritten.flat_match_query_execs += child_rewritten.flat_match_query_execs; + } + plan.with_new_children(new_children).map_err(|error| { +@@ -469,10 +470,15 @@ fn rewrite_prepared_fts_plan( + }; + + if let Some(exec) = rebuilt.downcast_ref::() { +- rewritten.match_query_execs += 1; ++ rewritten.indexed_query_execs += 1; + if !selected_segments_have_current_fragments { + return Ok((Arc::new(EmptyExec::new(rebuilt.schema())), rewritten)); + } ++ let PreparedFtsQuery::Match(scorer) = prepared else { ++ return Err(lance_core::Error::internal( ++ "prepared Phrase state cannot be attached to MatchQueryExec".to_string(), ++ )); ++ }; + let replacement = MatchQueryExec::new_with_segments( + Arc::clone(exec.dataset()), + exec.query().clone(), +@@ -483,6 +489,26 @@ fn rewrite_prepared_fts_plan( + .with_base_scorer(Arc::clone(scorer)); + return Ok((Arc::new(replacement), rewritten)); + } ++ if let Some(exec) = rebuilt.downcast_ref::() { ++ rewritten.indexed_query_execs += 1; ++ if !selected_segments_have_current_fragments { ++ return Ok((Arc::new(EmptyExec::new(rebuilt.schema())), rewritten)); ++ } ++ let PreparedFtsQuery::Phrase(scorer) = prepared else { ++ return Err(lance_core::Error::internal( ++ "prepared Match state cannot be attached to PhraseQueryExec".to_string(), ++ )); ++ }; ++ let replacement = PhraseQueryExec::new_with_segments( ++ Arc::clone(exec.dataset()), ++ exec.query().clone(), ++ exec.params().clone(), ++ exec.prefilter_source().clone(), ++ segments.to_vec(), ++ ) ++ .with_base_scorer(Arc::clone(scorer)); ++ return Ok((Arc::new(replacement), rewritten)); ++ } + Ok((rebuilt, rewritten)) + } + +@@ -2152,7 +2178,8 @@ mod tests { + use crate::dataset::{lance_dataset_close, lance_dataset_open}; + use crate::error::{lance_last_error_code, lance_last_error_message}; + use crate::fts_query::{ +- LanceFtsCoverageMode, lance_dataset_prepare_fts_query, lance_fts_query_context_close, ++ LanceFtsCoverageMode, LanceFtsMatchOperator, lance_dataset_prepare_fts_match_query, ++ lance_fts_query_context_close, + }; + use std::ffi::{CStr, CString}; + use std::sync::atomic::{AtomicI32, AtomicUsize}; +@@ -2276,10 +2303,11 @@ mod tests { + let column = CString::new("name").unwrap(); + let query = CString::new("a").unwrap(); + let context = unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::IndexOnly as i32, + ) +@@ -2293,7 +2321,6 @@ mod tests { + let prepared = unsafe { &*scanner }.build_scanner().unwrap(); + let distributed = prepared.distributed_fts.as_ref().unwrap(); + let segments = distributed.segments.clone(); +- let scorer = Arc::clone(&distributed.context.scorer); + let plan = block_on(prepared.scanner.create_plan()).unwrap(); + assert_eq!( + prepared_fts_plan_shape(&plan), +@@ -2303,9 +2330,14 @@ mod tests { + + let has_current_fragments = + segments_have_current_fragments(&distributed.context.dataset, &segments).unwrap(); +- let (rewritten, counts) = +- rewrite_prepared_fts_plan(plan, &segments, &scorer, has_current_fragments).unwrap(); +- assert_eq!(counts.match_query_execs, 1); ++ let (rewritten, counts) = rewrite_prepared_fts_plan( ++ plan, ++ &segments, ++ &distributed.context.prepared, ++ has_current_fragments, ++ ) ++ .unwrap(); ++ assert_eq!(counts.indexed_query_execs, 1); + assert_eq!(counts.flat_match_query_execs, 1); + assert_eq!(prepared_fts_plan_shape(&rewritten), (1, 0, 0)); + +diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs +index 8805764..a3e4ef7 100644 +--- a/tests/c_api_test.rs ++++ b/tests/c_api_test.rs +@@ -5762,6 +5762,203 @@ fn load_fts_segment_uuids(uri: &str, column: &str) -> Vec<[u8; 16]> { + }) + } + ++#[test] ++#[allow(deprecated)] ++fn test_prepared_fts_match_phrase_and_legacy_compatibility() { ++ let tmp = tempfile::tempdir().unwrap(); ++ let uri = tmp ++ .path() ++ .join("prepared_fts_queries") ++ .to_str() ++ .unwrap() ++ .to_string(); ++ let schema = Arc::new(Schema::new(vec![ ++ Field::new("id", DataType::Int32, false), ++ Field::new("text", DataType::Utf8, false), ++ ])); ++ let batch = RecordBatch::try_new( ++ schema.clone(), ++ vec![ ++ Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])), ++ Arc::new(StringArray::from(vec![ ++ "quick brown fox", ++ "quick blue fox", ++ "slow brown fox", ++ "quik brown fox", ++ "quick red brown fox", ++ ])), ++ ], ++ ) ++ .unwrap(); ++ lance_c::runtime::block_on(async { ++ Dataset::write( ++ arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema), ++ &uri, ++ None, ++ ) ++ .await ++ .unwrap(); ++ }); ++ ++ let uri_c = c_str(&uri); ++ let column = c_str("text"); ++ let index_params = ++ c_str(r#"{"base_tokenizer":"simple","language":"English","with_position":true}"#); ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ dataset, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ index_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ ++ let query = c_str("quick brown"); ++ let exact_or = unsafe { ++ lance_dataset_prepare_fts_match_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!exact_or.is_null()); ++ assert_eq!(collect_context_fts_scores(dataset, exact_or, None).len(), 5); ++ unsafe { lance_fts_query_context_close(exact_or) }; ++ ++ let legacy_or = unsafe { ++ lance_dataset_prepare_fts_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!legacy_or.is_null()); ++ assert_eq!( ++ collect_context_fts_scores(dataset, legacy_or, None).len(), ++ 5 ++ ); ++ unsafe { lance_fts_query_context_close(legacy_or) }; ++ ++ let exact_and = unsafe { ++ lance_dataset_prepare_fts_match_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ LanceFtsMatchOperator::And as i32, ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!exact_and.is_null()); ++ let exact_and_scores = collect_context_fts_scores(dataset, exact_and, None); ++ let mut exact_and_ids = exact_and_scores.keys().copied().collect::>(); ++ exact_and_ids.sort_unstable(); ++ assert_eq!(exact_and_ids, vec![1, 5]); ++ unsafe { lance_fts_query_context_close(exact_and) }; ++ ++ let fuzzy_and = unsafe { ++ lance_dataset_prepare_fts_match_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ LanceFtsMatchOperator::And as i32, ++ 1, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(fuzzy_and.is_null()); ++ let message = take_last_error_message(); ++ assert!( ++ message.contains("max_fuzzy_distance must be 0"), ++ "{message}" ++ ); ++ ++ let phrase = unsafe { ++ lance_dataset_prepare_fts_phrase_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!phrase.is_null(), "{}", take_last_error_message()); ++ let phrase_scores = collect_context_fts_scores(dataset, phrase, None); ++ assert_eq!(phrase_scores.keys().copied().collect::>(), vec![1]); ++ unsafe { lance_fts_query_context_close(phrase) }; ++ ++ let phrase_with_slop = unsafe { ++ lance_dataset_prepare_fts_phrase_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 1, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(!phrase_with_slop.is_null(), "{}", take_last_error_message()); ++ let phrase_with_slop_scores = collect_context_fts_scores(dataset, phrase_with_slop, None); ++ let mut phrase_with_slop_ids = phrase_with_slop_scores.keys().copied().collect::>(); ++ phrase_with_slop_ids.sort_unstable(); ++ assert_eq!(phrase_with_slop_ids, vec![1, 5]); ++ unsafe { lance_fts_query_context_close(phrase_with_slop) }; ++ ++ unsafe { lance_dataset_close(dataset) }; ++} ++ ++#[test] ++fn test_prepared_fts_phrase_requires_positions() { ++ let (_tmp, uri) = create_test_dataset(); ++ let uri_c = c_str(&uri); ++ let column = c_str("name"); ++ let query = c_str("alice smith"); ++ let index_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); ++ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; ++ assert_eq!( ++ unsafe { ++ lance_dataset_create_scalar_index( ++ dataset, ++ column.as_ptr(), ++ ptr::null(), ++ LanceScalarIndexType::Inverted as i32, ++ index_params.as_ptr(), ++ false, ++ ) ++ }, ++ 0 ++ ); ++ ++ let context = unsafe { ++ lance_dataset_prepare_fts_phrase_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(context.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ let message = take_last_error_message(); ++ assert!( ++ message.contains("does not store token positions"), ++ "{message}" ++ ); ++ ++ unsafe { lance_dataset_close(dataset) }; ++} ++ + #[test] + fn test_prepared_fts_row_id_output_is_explicit() { + let (_tmp, uri) = create_test_dataset(); +@@ -5785,10 +5982,11 @@ fn test_prepared_fts_row_id_output_is_explicit() { + 0 + ); + let context = unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::Strict as i32, + ) +@@ -5880,10 +6078,11 @@ fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { + + let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; + let strict = unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::Strict as i32, + ) +@@ -5898,10 +6097,11 @@ fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { + assert!(message.contains("unindexed fragments"), "{message}"); + + let context = unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::IndexOnly as i32, + ) +@@ -5976,10 +6176,11 @@ fn test_prepared_fts_index_only_empty_segment_returns_empty_shard() { + let query = c_str("alice"); + let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; + let context = unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::IndexOnly as i32, + ) +@@ -6075,10 +6276,11 @@ fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() { + + let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; + let context = unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::Strict as i32, + ) +@@ -6186,7 +6388,7 @@ fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() { + } + + #[test] +-fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { ++fn test_prepare_fts_queries_reject_invalid_inputs() { + let (_tmp, uri) = create_test_dataset(); + let uri_c = c_str(&uri); + let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; +@@ -6196,10 +6398,11 @@ fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { + + assert!( + unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + ptr::null(), + column.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::Strict as i32, + ) +@@ -6208,10 +6411,11 @@ fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { + ); + assert!( + unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + empty.as_ptr(), + query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, + 0, + LanceFtsCoverageMode::Strict as i32, + ) +@@ -6219,20 +6423,39 @@ fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { + .is_null() + ); + assert!( +- unsafe { lance_dataset_prepare_fts_query(dataset, column.as_ptr(), empty.as_ptr(), 0, 0) } +- .is_null() ++ unsafe { ++ lance_dataset_prepare_fts_match_query( ++ dataset, ++ column.as_ptr(), ++ empty.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ } ++ .is_null() + ); + assert!( +- unsafe { lance_dataset_prepare_fts_query(dataset, column.as_ptr(), query.as_ptr(), 0, 99) } +- .is_null() ++ unsafe { ++ lance_dataset_prepare_fts_match_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ LanceFtsMatchOperator::Or as i32, ++ 0, ++ 99, ++ ) ++ } ++ .is_null() + ); + assert!( + unsafe { +- lance_dataset_prepare_fts_query( ++ lance_dataset_prepare_fts_match_query( + dataset, + column.as_ptr(), + query.as_ptr(), +- 1, ++ 99, ++ 0, + LanceFtsCoverageMode::Strict as i32, + ) + } +@@ -6244,9 +6467,18 @@ fn test_prepare_fts_query_rejects_null_empty_invalid_mode_and_fuzzy() { + .to_string_lossy() + .into_owned() + }; ++ assert!(message.contains("invalid match_operator"), "{message}"); + assert!( +- message.contains("max_fuzzy_distance must be 0"), +- "{message}" ++ unsafe { ++ lance_dataset_prepare_fts_phrase_query( ++ dataset, ++ column.as_ptr(), ++ ptr::null(), ++ 0, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ } ++ .is_null() + ); + let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; + assert_eq!( + +From 9bb38749c9de185664511def3cbeb699ee60a38b Mon Sep 17 00:00:00 2001 +From: zhangstar333 +Date: Thu, 3 Sep 2026 17:49:02 +0800 +Subject: [PATCH 2/2] update slop i32 + +--- + include/lance/lance.h | 6 +++--- + include/lance/lance.hpp | 5 +++-- + src/fts_query.rs | 6 ++++-- + tests/c_api_test.rs | 14 ++++++++++++++ + 4 files changed, 24 insertions(+), 7 deletions(-) + +diff --git a/include/lance/lance.h b/include/lance/lance.h +index 0c76edc..25c0430 100644 +--- a/include/lance/lance.h ++++ b/include/lance/lance.h +@@ -1761,8 +1761,8 @@ LanceFtsQueryContext* lance_dataset_prepare_fts_match_query( + * Dataset identity, coverage, sharing, and segment-scoped execution follow the + * same contract as lance_dataset_prepare_fts_match_query(). + * +- * @param slop Maximum number of intervening token positions permitted between +- * adjacent phrase terms. ++ * @param slop Maximum non-negative number of intervening token positions ++ * permitted between adjacent phrase terms. + * @param coverage_mode Fixed-width LanceFtsCoverageMode discriminant. + * @return Context handle on success, or NULL on error. + */ +@@ -1770,7 +1770,7 @@ LanceFtsQueryContext* lance_dataset_prepare_fts_phrase_query( + const LanceDataset* dataset, + const char* column, + const char* query, +- uint32_t slop, ++ int32_t slop, + int32_t coverage_mode + ); + +diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp +index 973216a..1e69a06 100644 +--- a/include/lance/lance.hpp ++++ b/include/lance/lance.hpp +@@ -798,11 +798,12 @@ class Dataset { + return FtsQueryContext(context); + } + +- /// Prepare a Phrase query. Its FTS index must store token positions. ++ /// Prepare a Phrase query. Its FTS index must store token positions and ++ /// slop must be non-negative. + FtsQueryContext prepare_fts_phrase_query( + const std::string& column, + const std::string& query, +- uint32_t slop = 0, ++ int32_t slop = 0, + FtsCoverageMode coverage_mode = FtsCoverageMode::Strict) const { + auto* context = lance_dataset_prepare_fts_phrase_query( + handle_.get(), column.c_str(), query.c_str(), slop, +diff --git a/src/fts_query.rs b/src/fts_query.rs +index 3bf7f3e..c9d3a43 100644 +--- a/src/fts_query.rs ++++ b/src/fts_query.rs +@@ -401,7 +401,7 @@ pub unsafe extern "C" fn lance_dataset_prepare_fts_phrase_query( + dataset: *const LanceDataset, + column: *const c_char, + query: *const c_char, +- slop: u32, ++ slop: i32, + coverage_mode: i32, + ) -> *mut LanceFtsQueryContext { + ffi_try!( +@@ -414,9 +414,11 @@ unsafe fn prepare_fts_phrase_query_inner( + dataset: *const LanceDataset, + column: *const c_char, + query: *const c_char, +- slop: u32, ++ slop: i32, + coverage_mode: i32, + ) -> Result<*mut LanceFtsQueryContext> { ++ let slop = u32::try_from(slop) ++ .map_err(|_| invalid_input(format!("slop must be non-negative, got {slop}")))?; + let (snapshot, column, query_text, coverage_mode) = + unsafe { parse_query_inputs(dataset, column, query, coverage_mode)? }; + let query = FullTextSearchQuery::new_query( +diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs +index a3e4ef7..9411e15 100644 +--- a/tests/c_api_test.rs ++++ b/tests/c_api_test.rs +@@ -5914,6 +5914,20 @@ fn test_prepared_fts_match_phrase_and_legacy_compatibility() { + assert_eq!(phrase_with_slop_ids, vec![1, 5]); + unsafe { lance_fts_query_context_close(phrase_with_slop) }; + ++ let negative_phrase_slop = unsafe { ++ lance_dataset_prepare_fts_phrase_query( ++ dataset, ++ column.as_ptr(), ++ query.as_ptr(), ++ -1, ++ LanceFtsCoverageMode::Strict as i32, ++ ) ++ }; ++ assert!(negative_phrase_slop.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ let message = take_last_error_message(); ++ assert!(message.contains("slop must be non-negative"), "{message}"); ++ + unsafe { lance_dataset_close(dataset) }; + } + From 150acec00957ff2652316350ecb78bba6a385a7e Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Thu, 3 Sep 2026 20:04:52 +0800 Subject: [PATCH 6/7] [chore](lance) Upgrade Lance-C to v0.1.9 ### What problem does this PR solve? Issue Number: None Related PR: lance-format/lance-c#73, lance-format/lance-c#74 Problem Summary: Pick the Lance-C third-party state from commit 417cd5647b885cd06f1acb178af3dd36e6b34fbc into the branch-4.1 Lance FTS branch. Upgrade the bundled release from v0.1.8 to v0.1.9, replace the obsolete PR #69 patch with PR #73, and rename the existing byte-identical PR #74 patch for the v0.1.9 base. Keep the branch-specific third-party changes outside the Lance-C section unchanged. ### Release note None ### Check List (For Author) - Test: No need to test (verified shell syntax, patch blob equality, stale-reference removal, and scoped staged paths; compilation and tests were not requested) - Behavior changed: Yes. The bundled Lance-C version and patch baseline are updated. - Does this need documentation: No --- thirdparty/download-thirdparty.sh | 8 +- thirdparty/patches/lance-c-0.1.8-pr-69.patch | 653 ------ thirdparty/patches/lance-c-0.1.9-pr-73.patch | 2051 +++++++++++++++++ ...-pr-74.patch => lance-c-0.1.9-pr-74.patch} | 0 thirdparty/vars.sh | 8 +- 5 files changed, 2060 insertions(+), 660 deletions(-) delete mode 100644 thirdparty/patches/lance-c-0.1.8-pr-69.patch create mode 100644 thirdparty/patches/lance-c-0.1.9-pr-73.patch rename thirdparty/patches/{lance-c-0.1.8-pr-74.patch => lance-c-0.1.9-pr-74.patch} (100%) diff --git a/thirdparty/download-thirdparty.sh b/thirdparty/download-thirdparty.sh index ec251c33b50463..721960d72783ad 100755 --- a/thirdparty/download-thirdparty.sh +++ b/thirdparty/download-thirdparty.sh @@ -776,11 +776,13 @@ fi # Apply Doris lance-c patches. if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then - if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.8" ]]; then + if [[ "${LANCE_C_SOURCE}" == "lance-c-0.1.9" ]]; then cd "${TP_SOURCE_DIR}/${LANCE_C_SOURCE}" if [[ ! -f "${PATCHED_MARK}" ]]; then - patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.8-pr-69.patch" - patch -p1 <"${TP_PATCH_DIR}/lance-c-0.1.8-pr-74.patch" + patch --batch --forward --reject-file=- --fuzz=0 --no-backup-if-mismatch -s \ + -p1 <"${TP_PATCH_DIR}/lance-c-0.1.9-pr-73.patch" + patch --batch --forward --reject-file=- --fuzz=0 --no-backup-if-mismatch -s \ + -p1 <"${TP_PATCH_DIR}/lance-c-0.1.9-pr-74.patch" touch "${PATCHED_MARK}" fi cd - diff --git a/thirdparty/patches/lance-c-0.1.8-pr-69.patch b/thirdparty/patches/lance-c-0.1.8-pr-69.patch deleted file mode 100644 index ee38eb33bcb2fd..00000000000000 --- a/thirdparty/patches/lance-c-0.1.8-pr-69.patch +++ /dev/null @@ -1,653 +0,0 @@ -From a4d6e489c627fe4b0e49d9a2991c9436313debc0 Mon Sep 17 00:00:00 2001 -From: zhangstar333 -Date: Tue, 1 Sep 2026 11:15:00 +0800 -Subject: [PATCH 1/2] update - ---- - src/fts_query.rs | 2 ++ - src/scanner.rs | 44 ++++++++++++++++++++++--- - tests/c_api_test.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++ - 3 files changed, 120 insertions(+), 4 deletions(-) - -diff --git a/src/fts_query.rs b/src/fts_query.rs -index cd194c7..e85ed99 100644 ---- a/src/fts_query.rs -+++ b/src/fts_query.rs -@@ -54,6 +54,7 @@ pub(crate) struct FtsQueryContextInner { - pub(crate) query: FullTextSearchQuery, - pub(crate) segments: Vec, - pub(crate) scorer: Arc, -+ pub(crate) has_unindexed_fragments: bool, - } - - impl FtsQueryContextInner { -@@ -216,6 +217,7 @@ async fn prepare_fts_query_context( - query, - segments, - scorer, -+ has_unindexed_fragments: !unindexed_fragment_ids.is_empty(), - }) - } - -diff --git a/src/scanner.rs b/src/scanner.rs -index 5b8c34e..f60f0c5 100644 ---- a/src/scanner.rs -+++ b/src/scanner.rs -@@ -179,6 +179,45 @@ impl LanceScanner { - Ok(()) - } - -+ /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the -+ /// selected committed segments. This is deliberately separate from -+ /// `fast_search`: that option is scanner-wide, changes unrelated scalar -+ /// index fallback behavior, and also forces `_rowid` into the output. -+ fn apply_prepared_fts_fragment_filter( -+ &self, -+ scanner: &mut lance::dataset::scanner::Scanner, -+ context: &FtsQueryContextInner, -+ segments: &[IndexMetadata], -+ ) -> Result<()> { -+ if !context.has_unindexed_fragments { -+ return Ok(()); -+ } -+ -+ let mut selected_fragment_ids = std::collections::HashSet::new(); -+ for segment in segments { -+ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { -+ lance_core::Error::internal(format!( -+ "prepared FTS segment {} lost its validated fragment coverage", -+ segment.uuid -+ )) -+ })?; -+ selected_fragment_ids.extend(fragment_bitmap.iter()); -+ } -+ -+ let selected_fragments = self -+ .dataset -+ .get_fragments() -+ .into_iter() -+ .filter(|fragment| { -+ u32::try_from(fragment.id()) -+ .is_ok_and(|fragment_id| selected_fragment_ids.contains(&fragment_id)) -+ }) -+ .map(|fragment| fragment.metadata().clone()) -+ .collect(); -+ scanner.with_fragments(selected_fragments); -+ Ok(()) -+ } -+ - fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { - if let Some(substrait) = &self.substrait_filter { - scanner.filter_substrait(substrait)?; -@@ -282,11 +321,8 @@ impl LanceScanner { - let distributed_fts = if let Some(context) = &self.fts_context { - context.validate_dataset_identity(&self.dataset)?; - let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; -+ self.apply_prepared_fts_fragment_filter(&mut scanner, context, &segments)?; - scanner.full_text_search(context.query.clone())?; -- // Both STRICT and INDEX_ONLY context scans must use only the -- // committed segments pinned in the context. In STRICT mode all -- // current fragments were already proven covered during prepare. -- scanner.fast_search(); - Some(PreparedFtsExecution { - context: Arc::clone(context), - segments, -diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index 74b9f85..3627c4e 100644 ---- a/tests/c_api_test.rs -+++ b/tests/c_api_test.rs -@@ -5762,6 +5762,84 @@ fn load_fts_segment_uuids(uri: &str, column: &str) -> Vec<[u8; 16]> { - }) - } - -+#[test] -+fn test_prepared_fts_row_id_output_is_explicit() { -+ let (_tmp, uri) = create_test_dataset(); -+ let uri_c = c_str(&uri); -+ let column = c_str("name"); -+ let query = c_str("alice"); -+ let inverted_params = c_str(r#"{"base_tokenizer":"simple","language":"English"}"#); -+ -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ assert_eq!( -+ unsafe { -+ lance_dataset_create_scalar_index( -+ dataset, -+ column.as_ptr(), -+ ptr::null(), -+ LanceScalarIndexType::Inverted as i32, -+ inverted_params.as_ptr(), -+ false, -+ ) -+ }, -+ 0 -+ ); -+ let context = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::Strict as i32, -+ ) -+ }; -+ assert!(!context.is_null(), "{}", unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() -+ }); -+ -+ let id = c_str("id"); -+ let columns = [id.as_ptr(), ptr::null()]; -+ let scan_schema = |with_row_id: bool| { -+ let scanner = unsafe { lance_scanner_new(dataset, columns.as_ptr(), ptr::null()) }; -+ assert!(!scanner.is_null()); -+ if with_row_id { -+ assert_eq!(unsafe { lance_scanner_with_row_id(scanner, true) }, 0); -+ } -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, -+ 0 -+ ); -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0, -+ "{}", -+ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; -+ let schema = reader.schema(); -+ let rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert!(rows > 0); -+ unsafe { lance_scanner_close(scanner) }; -+ schema -+ }; -+ -+ let without_row_id = scan_schema(false); -+ assert_eq!(without_row_id.fields().len(), 2); -+ assert!(without_row_id.field_with_name("id").is_ok()); -+ assert!(without_row_id.field_with_name("_score").is_ok()); -+ assert!(without_row_id.field_with_name("_rowid").is_err()); -+ -+ let with_row_id = scan_schema(true); -+ assert_eq!(with_row_id.fields().len(), 3); -+ assert!(with_row_id.field_with_name("id").is_ok()); -+ assert!(with_row_id.field_with_name("_score").is_ok()); -+ assert!(with_row_id.field_with_name("_rowid").is_ok()); -+ -+ unsafe { lance_fts_query_context_close(context) }; -+ unsafe { lance_dataset_close(dataset) }; -+} -+ - #[test] - fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { - let (_tmp, uri) = create_test_dataset(); - -From 6f0fae4cc51bf144685564b1d2e9f6f7afc71f8a Mon Sep 17 00:00:00 2001 -From: zhangstar333 -Date: Tue, 1 Sep 2026 12:34:05 +0800 -Subject: [PATCH 2/2] update - ---- - src/fts_query.rs | 2 - - src/scanner.rs | 253 ++++++++++++++++++++++++++++++++++---------- - tests/c_api_test.rs | 79 ++++++++++++++ - 3 files changed, 279 insertions(+), 55 deletions(-) - -diff --git a/src/fts_query.rs b/src/fts_query.rs -index e85ed99..cd194c7 100644 ---- a/src/fts_query.rs -+++ b/src/fts_query.rs -@@ -54,7 +54,6 @@ pub(crate) struct FtsQueryContextInner { - pub(crate) query: FullTextSearchQuery, - pub(crate) segments: Vec, - pub(crate) scorer: Arc, -- pub(crate) has_unindexed_fragments: bool, - } - - impl FtsQueryContextInner { -@@ -217,7 +216,6 @@ async fn prepare_fts_query_context( - query, - segments, - scorer, -- has_unindexed_fragments: !unindexed_fragment_ids.is_empty(), - }) - } - -diff --git a/src/scanner.rs b/src/scanner.rs -index f60f0c5..0c29b17 100644 ---- a/src/scanner.rs -+++ b/src/scanner.rs -@@ -12,13 +12,13 @@ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; - - use arrow::ffi_stream::FFI_ArrowArrayStream; - use arrow_schema::SchemaRef; --use datafusion::physical_plan::ExecutionPlan; -+use datafusion::physical_plan::{ExecutionPlan, empty::EmptyExec}; - use futures::{FutureExt, Stream, StreamExt}; - use lance::Dataset; - use lance::dataset::scanner::{ - DatasetRecordBatchStream, ExecutionStatsCallback, ExecutionSummaryCounts, - }; --use lance::io::exec::fts::MatchQueryExec; -+use lance::io::exec::fts::{FlatMatchQueryExec, MatchQueryExec}; - use lance_core::Result; - use lance_index::scalar::FullTextSearchQuery; - use lance_io::stream::RecordBatchStream; -@@ -179,45 +179,6 @@ impl LanceScanner { - Ok(()) - } - -- /// Restrict an INDEX_ONLY prepared FTS scan to fragments covered by the -- /// selected committed segments. This is deliberately separate from -- /// `fast_search`: that option is scanner-wide, changes unrelated scalar -- /// index fallback behavior, and also forces `_rowid` into the output. -- fn apply_prepared_fts_fragment_filter( -- &self, -- scanner: &mut lance::dataset::scanner::Scanner, -- context: &FtsQueryContextInner, -- segments: &[IndexMetadata], -- ) -> Result<()> { -- if !context.has_unindexed_fragments { -- return Ok(()); -- } -- -- let mut selected_fragment_ids = std::collections::HashSet::new(); -- for segment in segments { -- let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { -- lance_core::Error::internal(format!( -- "prepared FTS segment {} lost its validated fragment coverage", -- segment.uuid -- )) -- })?; -- selected_fragment_ids.extend(fragment_bitmap.iter()); -- } -- -- let selected_fragments = self -- .dataset -- .get_fragments() -- .into_iter() -- .filter(|fragment| { -- u32::try_from(fragment.id()) -- .is_ok_and(|fragment_id| selected_fragment_ids.contains(&fragment_id)) -- }) -- .map(|fragment| fragment.metadata().clone()) -- .collect(); -- scanner.with_fragments(selected_fragments); -- Ok(()) -- } -- - fn apply_filter(&self, scanner: &mut lance::dataset::scanner::Scanner) -> Result<()> { - if let Some(substrait) = &self.substrait_filter { - scanner.filter_substrait(substrait)?; -@@ -321,7 +282,6 @@ impl LanceScanner { - let distributed_fts = if let Some(context) = &self.fts_context { - context.validate_dataset_identity(&self.dataset)?; - let segments = select_fts_segments(context, self.fts_index_segments.as_deref())?; -- self.apply_prepared_fts_fragment_filter(&mut scanner, context, &segments)?; - scanner.full_text_search(context.query.clone())?; - Some(PreparedFtsExecution { - context: Arc::clone(context), -@@ -361,14 +321,24 @@ impl PreparedScanner { - return self.scanner.try_into_stream().await; - }; - let plan = self.scanner.create_plan().await?; -- let (plan, replaced) = replace_match_query_exec( -+ let selected_segments_have_current_fragments = segments_have_current_fragments( -+ &distributed_fts.context.dataset, -+ &distributed_fts.segments, -+ )?; -+ let (plan, rewritten) = rewrite_prepared_fts_plan( - plan, - &distributed_fts.segments, - &distributed_fts.context.scorer, -+ selected_segments_have_current_fragments, - )?; -- if replaced != 1 { -+ if rewritten.match_query_execs > 1 -+ || rewritten.flat_match_query_execs > 1 -+ || rewritten.match_query_execs + rewritten.flat_match_query_execs == 0 -+ || (selected_segments_have_current_fragments && rewritten.match_query_execs != 1) -+ { - return Err(lance_core::Error::internal(format!( -- "expected exactly one MatchQueryExec in prepared FTS plan, replaced {replaced}" -+ "unexpected prepared FTS plan for selected segments with current fragment coverage {selected_segments_have_current_fragments}: rewrote {} MatchQueryExec node(s) and removed {} FlatMatchQueryExec node(s)", -+ rewritten.match_query_execs, rewritten.flat_match_query_execs - ))); - } - let stream = lance_datafusion::exec::execute_plan( -@@ -415,22 +385,81 @@ fn select_fts_segments( - Ok(selected) - } - --fn replace_match_query_exec( -+fn segments_have_current_fragments( -+ dataset: &lance::Dataset, -+ segments: &[IndexMetadata], -+) -> Result { -+ let current_fragment_ids = dataset -+ .get_fragments() -+ .into_iter() -+ .map(|fragment| { -+ u32::try_from(fragment.id()).map_err(|_| { -+ lance_core::Error::internal(format!( -+ "current fragment id {} exceeds the validated u32 FTS coverage range", -+ fragment.id() -+ )) -+ }) -+ }) -+ .collect::>>()?; -+ for segment in segments { -+ let fragment_bitmap = segment.fragment_bitmap.as_ref().ok_or_else(|| { -+ lance_core::Error::internal(format!( -+ "prepared FTS segment {} lost its validated fragment coverage", -+ segment.uuid -+ )) -+ })?; -+ if fragment_bitmap -+ .iter() -+ .any(|fragment_id| current_fragment_ids.contains(&fragment_id)) -+ { -+ return Ok(true); -+ } -+ } -+ Ok(false) -+} -+ -+#[derive(Default)] -+struct PreparedFtsPlanRewriteCounts { -+ match_query_execs: usize, -+ flat_match_query_execs: usize, -+} -+ -+fn rewrite_prepared_fts_plan( - plan: Arc, - segments: &[IndexMetadata], - scorer: &Arc, --) -> Result<(Arc, usize)> { -+ selected_segments_have_current_fragments: bool, -+) -> Result<(Arc, PreparedFtsPlanRewriteCounts)> { -+ // Lance's ordinary FTS planner adds a flat-search branch for fragments not -+ // covered by the logical index. A prepared INDEX_ONLY scan must omit that -+ // branch, but using Scanner::with_fragments to do so would turn an -+ // otherwise unfiltered index search into a full row-id prefilter scan. -+ if plan.downcast_ref::().is_some() { -+ return Ok(( -+ Arc::new(EmptyExec::new(plan.schema())), -+ PreparedFtsPlanRewriteCounts { -+ match_query_execs: 0, -+ flat_match_query_execs: 1, -+ }, -+ )); -+ } -+ - let children = plan.children(); -- let mut replaced = 0; -+ let mut rewritten = PreparedFtsPlanRewriteCounts::default(); - let rebuilt = if children.is_empty() { - plan - } else { - let mut new_children = Vec::with_capacity(children.len()); - for child in children { -- let (new_child, child_replaced) = -- replace_match_query_exec(Arc::clone(child), segments, scorer)?; -+ let (new_child, child_rewritten) = rewrite_prepared_fts_plan( -+ Arc::clone(child), -+ segments, -+ scorer, -+ selected_segments_have_current_fragments, -+ )?; - new_children.push(new_child); -- replaced += child_replaced; -+ rewritten.match_query_execs += child_rewritten.match_query_execs; -+ rewritten.flat_match_query_execs += child_rewritten.flat_match_query_execs; - } - plan.with_new_children(new_children).map_err(|error| { - lance_core::Error::internal(format!( -@@ -440,6 +469,10 @@ fn replace_match_query_exec( - }; - - if let Some(exec) = rebuilt.downcast_ref::() { -+ rewritten.match_query_execs += 1; -+ if !selected_segments_have_current_fragments { -+ return Ok((Arc::new(EmptyExec::new(rebuilt.schema())), rewritten)); -+ } - let replacement = MatchQueryExec::new_with_segments( - Arc::clone(exec.dataset()), - exec.query().clone(), -@@ -448,9 +481,9 @@ fn replace_match_query_exec( - segments.to_vec(), - ) - .with_base_scorer(Arc::clone(scorer)); -- return Ok((Arc::new(replacement), replaced + 1)); -+ return Ok((Arc::new(replacement), rewritten)); - } -- Ok((rebuilt, replaced)) -+ Ok((rebuilt, rewritten)) - } - - /// Type of a dynamically named scan metric. -@@ -2118,6 +2151,9 @@ mod tests { - use super::*; - use crate::dataset::{lance_dataset_close, lance_dataset_open}; - use crate::error::{lance_last_error_code, lance_last_error_message}; -+ use crate::fts_query::{ -+ LanceFtsCoverageMode, lance_dataset_prepare_fts_query, lance_fts_query_context_close, -+ }; - use std::ffi::{CStr, CString}; - use std::sync::atomic::{AtomicI32, AtomicUsize}; - use std::sync::{Barrier, mpsc}; -@@ -2125,6 +2161,9 @@ mod tests { - - use arrow_array::{Int32Array, RecordBatch, StringArray}; - use arrow_schema::{DataType, Field, Schema}; -+ use lance::index::DatasetIndexExt; -+ use lance::io::exec::PreFilterSource; -+ use lance_index::{IndexType, scalar::InvertedIndexParams}; - - /// Write a 3-row dataset to a tempdir, returning (tempdir, uri). - fn create_test_dataset() -> (tempfile::TempDir, String) { -@@ -2169,6 +2208,114 @@ mod tests { - .store(true, Ordering::SeqCst); - } - -+ fn prepared_fts_plan_shape(plan: &Arc) -> (usize, usize, usize) { -+ let mut match_query_execs = 0; -+ let mut flat_match_query_execs = 0; -+ let mut filtered_row_id_prefilters = 0; -+ if let Some(exec) = plan.downcast_ref::() { -+ match_query_execs += 1; -+ if matches!(exec.prefilter_source(), PreFilterSource::FilteredRowIds(_)) { -+ filtered_row_id_prefilters += 1; -+ } -+ } -+ if plan.downcast_ref::().is_some() { -+ flat_match_query_execs += 1; -+ } -+ for child in plan.children() { -+ let (child_match, child_flat, child_filtered) = -+ prepared_fts_plan_shape(&Arc::clone(child)); -+ match_query_execs += child_match; -+ flat_match_query_execs += child_flat; -+ filtered_row_id_prefilters += child_filtered; -+ } -+ ( -+ match_query_execs, -+ flat_match_query_execs, -+ filtered_row_id_prefilters, -+ ) -+ } -+ -+ #[test] -+ fn prepared_fts_index_only_plan_does_not_scan_indexed_fragment_row_ids() { -+ let (_tmp, uri) = create_test_dataset(); -+ block_on(async { -+ let mut dataset = Dataset::open(&uri).await.unwrap(); -+ dataset -+ .create_index( -+ &["name"], -+ IndexType::Inverted, -+ None, -+ &InvertedIndexParams::default(), -+ false, -+ ) -+ .await -+ .unwrap(); -+ -+ let schema = Arc::new(Schema::new(vec![ -+ Field::new("id", DataType::Int32, false), -+ Field::new("name", DataType::Utf8, true), -+ ])); -+ let batch = RecordBatch::try_new( -+ schema.clone(), -+ vec![ -+ Arc::new(Int32Array::from(vec![4])), -+ Arc::new(StringArray::from(vec!["a"])), -+ ], -+ ) -+ .unwrap(); -+ dataset -+ .append( -+ arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema), -+ None, -+ ) -+ .await -+ .unwrap(); -+ }); -+ -+ let (dataset, scanner) = open_dataset_and_scanner(&uri); -+ let column = CString::new("name").unwrap(); -+ let query = CString::new("a").unwrap(); -+ let context = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::IndexOnly as i32, -+ ) -+ }; -+ assert!(!context.is_null()); -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, -+ 0 -+ ); -+ -+ let prepared = unsafe { &*scanner }.build_scanner().unwrap(); -+ let distributed = prepared.distributed_fts.as_ref().unwrap(); -+ let segments = distributed.segments.clone(); -+ let scorer = Arc::clone(&distributed.context.scorer); -+ let plan = block_on(prepared.scanner.create_plan()).unwrap(); -+ assert_eq!( -+ prepared_fts_plan_shape(&plan), -+ (1, 1, 0), -+ "an unfiltered prepared FTS plan must not materialize selected fragment row IDs" -+ ); -+ -+ let has_current_fragments = -+ segments_have_current_fragments(&distributed.context.dataset, &segments).unwrap(); -+ let (rewritten, counts) = -+ rewrite_prepared_fts_plan(plan, &segments, &scorer, has_current_fragments).unwrap(); -+ assert_eq!(counts.match_query_execs, 1); -+ assert_eq!(counts.flat_match_query_execs, 1); -+ assert_eq!(prepared_fts_plan_shape(&rewritten), (1, 0, 0)); -+ -+ unsafe { -+ lance_scanner_close(scanner); -+ lance_fts_query_context_close(context); -+ lance_dataset_close(dataset); -+ } -+ } -+ - /// Assert the pending thread-local error is `Panic` carrying the poison - /// message; consumes it so the next assertion starts from a clean slate. - fn assert_poison_error_pending() { -diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs -index 3627c4e..8805764 100644 ---- a/tests/c_api_test.rs -+++ b/tests/c_api_test.rs -@@ -5943,6 +5943,85 @@ fn test_prepare_fts_query_index_only_allows_unindexed_fragment() { - unsafe { lance_dataset_close(dataset) }; - } - -+#[test] -+fn test_prepared_fts_index_only_empty_segment_returns_empty_shard() { -+ use lance::index::DatasetIndexExt; -+ use lance_index::{IndexType, scalar::InvertedIndexParams}; -+ -+ let (_tmp, uri) = create_test_dataset(); -+ lance_c::runtime::block_on(async { -+ let mut dataset = Dataset::open(&uri).await.unwrap(); -+ let params = InvertedIndexParams::default(); -+ dataset -+ .create_index_builder(&["name"], IndexType::Inverted, ¶ms) -+ .name("empty_name_fts".to_string()) -+ .train(false) -+ .await -+ .unwrap(); -+ let segments = dataset -+ .load_indices_by_name("empty_name_fts") -+ .await -+ .unwrap(); -+ assert_eq!(segments.len(), 1); -+ assert!( -+ segments[0] -+ .fragment_bitmap -+ .as_ref() -+ .is_some_and(|fragment_bitmap| fragment_bitmap.is_empty()) -+ ); -+ }); -+ -+ let uri_c = c_str(&uri); -+ let column = c_str("name"); -+ let query = c_str("alice"); -+ let dataset = unsafe { lance_dataset_open(uri_c.as_ptr(), ptr::null(), 0) }; -+ let context = unsafe { -+ lance_dataset_prepare_fts_query( -+ dataset, -+ column.as_ptr(), -+ query.as_ptr(), -+ 0, -+ LanceFtsCoverageMode::IndexOnly as i32, -+ ) -+ }; -+ assert!(!context.is_null(), "{}", unsafe { -+ std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() -+ }); -+ let segment_uuids = load_fts_segment_uuids(&uri, "name"); -+ assert_eq!(segment_uuids.len(), 1); -+ -+ let scanner = unsafe { lance_scanner_new(dataset, ptr::null(), ptr::null()) }; -+ assert_eq!( -+ unsafe { lance_scanner_set_fts_query_context(scanner, context) }, -+ 0 -+ ); -+ assert_eq!( -+ unsafe { -+ lance_scanner_set_fts_index_segments( -+ scanner, -+ segment_uuids.as_ptr().cast::(), -+ segment_uuids.len(), -+ ) -+ }, -+ 0 -+ ); -+ -+ let mut stream = FFI_ArrowArrayStream::empty(); -+ assert_eq!( -+ unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream) }, -+ 0, -+ "{}", -+ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message()).to_string_lossy() } -+ ); -+ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream).unwrap() }; -+ let total_rows: usize = reader.map(|batch| batch.unwrap().num_rows()).sum(); -+ assert_eq!(total_rows, 0); -+ -+ unsafe { lance_scanner_close(scanner) }; -+ unsafe { lance_fts_query_context_close(context) }; -+ unsafe { lance_dataset_close(dataset) }; -+} -+ - #[test] - fn test_prepared_fts_global_scorer_is_shared_across_segment_splits() { - use lance::index::DatasetIndexExt; diff --git a/thirdparty/patches/lance-c-0.1.9-pr-73.patch b/thirdparty/patches/lance-c-0.1.9-pr-73.patch new file mode 100644 index 00000000000000..453ef9f9c6c74c --- /dev/null +++ b/thirdparty/patches/lance-c-0.1.9-pr-73.patch @@ -0,0 +1,2051 @@ +From a39bc8b08cd1367b7445cb9acf3309faa1f3d610 Mon Sep 17 00:00:00 2001 +From: zhangstar333 +Date: Thu, 3 Sep 2026 13:00:43 +0800 +Subject: [PATCH] foyer + +--- + Cargo.lock | 205 ++++++++ + Cargo.toml | 4 + + README.md | 22 + + include/lance/lance.h | 62 +++ + include/lance/lance.hpp | 29 ++ + src/data_cache.rs | 68 +++ + src/dataset.rs | 11 + + src/foyer_data_cache.rs | 950 +++++++++++++++++++++++++++++++++++++ + src/lib.rs | 4 + + src/restore.rs | 8 + + src/session.rs | 13 +- + src/writer.rs | 1 + + tests/c_api_test.rs | 221 +++++++++ + tests/cpp/test_c_api.c | 32 ++ + tests/cpp/test_cpp_api.cpp | 23 + + 15 files changed, 1652 insertions(+), 1 deletion(-) + create mode 100644 src/data_cache.rs + create mode 100644 src/foyer_data_cache.rs + +diff --git a/Cargo.lock b/Cargo.lock +index 60c1caf..85536f5 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -434,6 +434,16 @@ dependencies = [ + "loom", + ] + ++[[package]] ++name = "asyncband" ++version = "0.6.7" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" ++dependencies = [ ++ "hashbrown 0.17.1", ++ "slab", ++] ++ + [[package]] + name = "atoi" + version = "2.0.0" +@@ -1267,6 +1277,17 @@ version = "0.8.7" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + ++[[package]] ++name = "core_affinity" ++version = "0.8.3" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" ++dependencies = [ ++ "libc", ++ "num_cpus", ++ "winapi", ++] ++ + [[package]] + name = "countio" + version = "0.3.0" +@@ -2112,6 +2133,12 @@ dependencies = [ + "url", + ] + ++[[package]] ++name = "datasketches" ++version = "0.3.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "46c4cf71a36b46dcfc00e5014c0c20ccad2b1b6a008304d7d57d2749b2d41b3d" ++ + [[package]] + name = "der" + version = "0.7.10" +@@ -2298,6 +2325,16 @@ version = "0.2.3" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + ++[[package]] ++name = "fastant" ++version = "0.1.11" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07" ++dependencies = [ ++ "small_ctor", ++ "web-time", ++] ++ + [[package]] + name = "fastrand" + version = "2.3.0" +@@ -2369,6 +2406,127 @@ dependencies = [ + "percent-encoding", + ] + ++[[package]] ++name = "foyer" ++version = "0.22.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "cab5c4bac30455a0dbc4c858436fb440d51bc6543daafbdf35c5e6ca94c0afd7" ++dependencies = [ ++ "anyhow", ++ "asyncband", ++ "equivalent", ++ "foyer-common", ++ "foyer-memory", ++ "foyer-storage", ++ "foyer-tokio", ++ "futures-util", ++ "mixtrics", ++ "pin-project", ++ "serde", ++ "tracing", ++] ++ ++[[package]] ++name = "foyer-common" ++version = "0.22.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "e3634c6f3da978b6cae98d54367a2342041d3a4786b20c0384164cc7fce94787" ++dependencies = [ ++ "anyhow", ++ "bytes", ++ "cfg-if 1.0.4", ++ "foyer-tokio", ++ "mixtrics", ++ "parking_lot", ++ "pin-project", ++ "twox-hash", ++] ++ ++[[package]] ++name = "foyer-intrusive-collections" ++version = "0.10.0-dev" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" ++dependencies = [ ++ "memoffset", ++] ++ ++[[package]] ++name = "foyer-memory" ++version = "0.22.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "5349a61af676b3275bfef7a5fceceeadf6b0a7dd9ec14ba0edd78e1ff771d101" ++dependencies = [ ++ "anyhow", ++ "asyncband", ++ "bitflags", ++ "datasketches", ++ "equivalent", ++ "foyer-common", ++ "foyer-intrusive-collections", ++ "foyer-tokio", ++ "futures-util", ++ "hashbrown 0.17.1", ++ "itertools 0.15.0", ++ "mixtrics", ++ "parking_lot", ++ "paste", ++ "pin-project", ++ "serde", ++ "tracing", ++] ++ ++[[package]] ++name = "foyer-storage" ++version = "0.22.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "49cfcce3c10a1f2ac65bfcf0bd85501462d454c594c8a16a4a27cce773599381" ++dependencies = [ ++ "allocator-api2", ++ "anyhow", ++ "asyncband", ++ "bytes", ++ "core_affinity", ++ "equivalent", ++ "fastant", ++ "foyer-common", ++ "foyer-memory", ++ "foyer-tokio", ++ "fs4", ++ "futures-core", ++ "futures-util", ++ "hashbrown 0.17.1", ++ "io-uring", ++ "itertools 0.15.0", ++ "libc", ++ "lz4", ++ "parking_lot", ++ "pin-project", ++ "rand 0.10.1", ++ "tracing", ++ "twox-hash", ++ "zstd", ++] ++ ++[[package]] ++name = "foyer-tokio" ++version = "0.22.4" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "6b7315103199f3415a010befd6dd5a1c8e7082fbc49c0194931e65629995d9d1" ++dependencies = [ ++ "tokio", ++] ++ ++[[package]] ++name = "fs4" ++version = "0.13.1" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" ++dependencies = [ ++ "rustix", ++ "windows-sys 0.59.0", ++] ++ + [[package]] + name = "fs_extra" + version = "1.3.0" +@@ -3392,6 +3550,15 @@ dependencies = [ + "either", + ] + ++[[package]] ++name = "itertools" ++version = "0.15.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" ++dependencies = [ ++ "either", ++] ++ + [[package]] + name = "itoa" + version = "1.0.18" +@@ -3703,8 +3870,11 @@ dependencies = [ + "arrow", + "arrow-array", + "arrow-schema", ++ "async-trait", ++ "bytes", + "chrono", + "datafusion", ++ "foyer", + "futures", + "half", + "lance", +@@ -3718,6 +3888,7 @@ dependencies = [ + "lance-table", + "libc", + "log", ++ "object_store", + "pin-project", + "prost", + "snafu", +@@ -4399,6 +4570,15 @@ version = "2.8.0" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + ++[[package]] ++name = "memoffset" ++version = "0.9.1" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" ++dependencies = [ ++ "autocfg", ++] ++ + [[package]] + name = "mime" + version = "0.3.17" +@@ -4436,6 +4616,16 @@ dependencies = [ + "windows-sys 0.61.2", + ] + ++[[package]] ++name = "mixtrics" ++version = "0.2.5" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9" ++dependencies = [ ++ "itertools 0.15.0", ++ "parking_lot", ++] ++ + [[package]] + name = "moka" + version = "0.12.15" +@@ -6547,6 +6737,12 @@ version = "0.4.12" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + ++[[package]] ++name = "small_ctor" ++version = "0.1.2" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81" ++ + [[package]] + name = "smallvec" + version = "1.15.1" +@@ -7831,6 +8027,15 @@ dependencies = [ + "windows-targets 0.52.6", + ] + ++[[package]] ++name = "windows-sys" ++version = "0.59.0" ++source = "registry+https://github.com/rust-lang/crates.io-index" ++checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" ++dependencies = [ ++ "windows-targets 0.52.6", ++] ++ + [[package]] + name = "windows-sys" + version = "0.60.2" +diff --git a/Cargo.toml b/Cargo.toml +index d072a5d..342928c 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -30,6 +30,8 @@ datafusion = { version = "54.0.0", default-features = false } + arrow = { version = "58.0.0", features = ["prettyprint", "ffi"] } + arrow-array = "58.0.0" + arrow-schema = "58.0.0" ++async-trait = "0.1" ++bytes = "1" + # Direct to name `chrono::TimeDelta` (the field type of lance's public + # `AutoCleanupParams`) and `chrono::DateTime`/`Utc` (index metadata + # timestamps); already in the graph transitively via lance. +@@ -37,8 +39,10 @@ chrono = { version = "0.4", default-features = false } + half = "2" + tokio = { version = "1", features = ["rt-multi-thread", "sync"] } + futures = "0.3" ++foyer = "=0.22.4" + log = "0.4" + libc = "0.2" ++object_store = "0.13.2" + pin-project = "1.0" + prost = "0.14" + snafu = "0.9" +diff --git a/README.md b/README.md +index 2056671..99502d9 100644 +--- a/README.md ++++ b/README.md +@@ -68,6 +68,7 @@ Based on the [liblance RFC](https://github.com/lance-format/lance/discussions/60 + | [x] | Async scan | Callback-based `lance_scanner_scan_async()` for non-blocking scans | + | [x] | Dataset metadata | `lance_dataset_version()`, `lance_dataset_count_rows()`, `lance_dataset_latest_version()` | + | [x] | Filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression`; `lance_scanner_additional_sql_filter()` adds SQL predicates with AND before scanning starts | ++| [x] | Data-file cache | Optional Foyer memory/disk cache for immutable `data/*.lance` batched range reads | + + ## Building + +@@ -197,6 +198,27 @@ auto ds = lance::Dataset::open_with_session(session, "data.lance"); + auto stats = session.cache_stats(); + ``` + ++To add a process-local memory/disk cache for remote Lance data-file reads, ++create the session with Foyer configuration. The cache is deliberately narrow: ++only batched range reads of direct `data/*.lance` children are cached. Single ++range reads, manifests, deletion files, and index files keep using Lance's ++normal paths. Use one shared session for datasets that share the cache ++directory. ++ ++```cpp ++lance::DataCacheOptions data_cache{ ++ "/var/cache/my-service/lance", ++ 512ULL * 1024 * 1024, // memory tier ++ 100ULL * 1024 * 1024 * 1024, // disk tier ++ 1ULL * 1024 * 1024, // range-cache block ++}; ++lance::Session session( ++ 6ULL * 1024 * 1024 * 1024, ++ 1ULL * 1024 * 1024 * 1024, ++ data_cache); ++auto ds = lance::Dataset::open_with_session(session, "s3://bucket/data.lance"); ++``` ++ + ### Open at a specific version + + `lance_dataset_open` takes a `version` argument — `0` means the latest, any +diff --git a/include/lance/lance.h b/include/lance/lance.h +index 3bf291f..2cd5be0 100644 +--- a/include/lance/lance.h ++++ b/include/lance/lance.h +@@ -206,6 +206,35 @@ typedef struct LanceSessionCacheStats { + uint64_t metadata_cache_size_bytes; + } LanceSessionCacheStats; + ++/** ++ * Configuration for the optional Foyer cache of immutable Lance data files. ++ * ++ * Only batched range reads of direct `data/.lance` children are cached. ++ * Single-range reads, metadata, deletion files, and index files continue to ++ * use Lance's normal paths. ++ */ ++typedef struct LanceDataCacheOptions { ++ const char* directory; ++ /** Maximum raw data bytes retained by Foyer's in-memory tier. */ ++ uint64_t memory_capacity_bytes; ++ /** Maximum bytes allocated to Foyer's disk tier. */ ++ uint64_t disk_capacity_bytes; ++ /** Data-file range cache unit, in bytes. */ ++ uint64_t read_block_size_bytes; ++} LanceDataCacheOptions; ++ ++/** ++ * Cumulative Foyer data-cache statistics for one opened dataset handle. ++ * ++ * Successful reads are accumulated. Both fields measure bytes returned to the ++ * dataset reader. Their sum is the logical data-file range bytes observed by ++ * the Foyer wrapper; block-aligned origin read amplification is not included. ++ */ ++typedef struct LanceDataCacheStatistics { ++ uint64_t bytes_read_from_cache; ++ uint64_t bytes_read_from_remote; ++} LanceDataCacheStatistics; ++ + /** + * Create a session that can share metadata and index caches across datasets. + * +@@ -217,6 +246,25 @@ LanceSession* lance_session_new( + uint64_t metadata_cache_size_bytes + ); + ++/** ++ * Create a shared Lance session with a Foyer data-file cache. ++ * ++ * `data_cache_options` and its `directory` field must not be NULL. The cache ++ * directory and all capacities are process configuration and remain owned by ++ * the caller; their values are copied during this call. ++ * ++ * `read_block_size_bytes` must be a non-zero multiple of 4096. The memory ++ * capacity must hold at least one read block. The disk capacity must be a ++ * multiple of 4096 and hold at least two read blocks. ++ * ++ * @return Session handle, or NULL on error ++ */ ++LanceSession* lance_session_new_with_data_cache( ++ uint64_t index_cache_size_bytes, ++ uint64_t metadata_cache_size_bytes, ++ const LanceDataCacheOptions* data_cache_options ++); ++ + /** + * Close a session handle. Safe to call with NULL. Datasets previously opened + * with the session remain valid and retain the shared cache state. +@@ -273,6 +321,20 @@ LanceDataset* lance_dataset_open_with_session( + const LanceSession* session + ); + ++/** ++ * Copy this dataset handle's cumulative data-cache statistics. ++ * ++ * A dataset not opened with a data cache reports all-zero statistics. The ++ * snapshot belongs only to this dataset handle; the underlying cache may ++ * still be shared by other datasets through a session. ++ * ++ * @return 0 on success, -1 on error ++ */ ++int32_t lance_dataset_get_data_cache_statistics( ++ const LanceDataset* dataset, ++ LanceDataCacheStatistics* out_statistics ++); ++ + /** Close and free a dataset handle. Safe to call with NULL. */ + void lance_dataset_close(LanceDataset* dataset); + +diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp +index 6cf245f..c1102e4 100644 +--- a/include/lance/lance.hpp ++++ b/include/lance/lance.hpp +@@ -171,6 +171,13 @@ struct SqlColumn { + + // ─── Shared Session ────────────────────────────────────────────────────────── + ++struct DataCacheOptions { ++ std::string directory; ++ uint64_t memory_capacity_bytes; ++ uint64_t disk_capacity_bytes; ++ uint64_t read_block_size_bytes; ++}; ++ + class Session { + Handle handle_; + +@@ -180,6 +187,21 @@ class Session { + if (!handle_) check_error(); + } + ++ Session(uint64_t index_cache_size_bytes, ++ uint64_t metadata_cache_size_bytes, ++ const DataCacheOptions& data_cache_options) { ++ LanceDataCacheOptions options{ ++ data_cache_options.directory.c_str(), ++ data_cache_options.memory_capacity_bytes, ++ data_cache_options.disk_capacity_bytes, ++ data_cache_options.read_block_size_bytes, ++ }; ++ handle_ = Handle( ++ lance_session_new_with_data_cache( ++ index_cache_size_bytes, metadata_cache_size_bytes, &options)); ++ if (!handle_) check_error(); ++ } ++ + LanceSessionCacheStats cache_stats() const { + LanceSessionCacheStats stats{}; + if (lance_session_get_cache_stats(handle_.get(), &stats) != 0) +@@ -260,6 +282,13 @@ class Dataset { + return Dataset(ds); + } + ++ LanceDataCacheStatistics data_cache_statistics() const { ++ LanceDataCacheStatistics statistics{}; ++ if (lance_dataset_get_data_cache_statistics(handle_.get(), &statistics) != 0) ++ check_error(); ++ return statistics; ++ } ++ + /// Write an Arrow record batch stream to a Lance dataset and return the + /// open dataset at the committed version. + /// +diff --git a/src/data_cache.rs b/src/data_cache.rs +new file mode 100644 +index 0000000..430b4c8 +--- /dev/null ++++ b/src/data_cache.rs +@@ -0,0 +1,68 @@ ++// SPDX-License-Identifier: Apache-2.0 ++// SPDX-FileCopyrightText: Copyright The Lance Authors ++ ++//! Private bridge between a shared data-cache backend and dataset handles. ++ ++use std::fmt::Debug; ++use std::sync::Arc; ++ ++use lance::Dataset; ++use lance_core::Result; ++ ++use crate::dataset::LanceDataset; ++use crate::error::ffi_try; ++ ++/// Data-cache statistics owned by one opened dataset. ++#[repr(C)] ++#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] ++pub struct LanceDataCacheStatistics { ++ /// Requested bytes returned from usable data-cache entries. ++ pub bytes_read_from_cache: u64, ++ /// Requested bytes returned after a data-cache miss or fallback. ++ pub bytes_read_from_remote: u64, ++} ++ ++pub(crate) trait DatasetDataCache: Debug + Send + Sync { ++ fn snapshot(&self) -> LanceDataCacheStatistics; ++ ++ fn attach_fresh(&self, dataset: Dataset) -> (Dataset, Arc); ++} ++ ++pub(crate) trait DataCacheFactory: Debug + Send + Sync { ++ fn attach(&self, dataset: Dataset) -> (Dataset, Arc); ++} ++ ++/// Copy this dataset handle's cumulative data-cache statistics into ++/// `out_statistics`. ++/// ++/// A dataset not opened with a data cache reports all-zero statistics. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_dataset_get_data_cache_statistics( ++ dataset: *const LanceDataset, ++ out_statistics: *mut LanceDataCacheStatistics, ++) -> i32 { ++ ffi_try!( ++ unsafe { dataset_get_data_cache_statistics_inner(dataset, out_statistics) }, ++ neg ++ ) ++} ++ ++unsafe fn dataset_get_data_cache_statistics_inner( ++ dataset: *const LanceDataset, ++ out_statistics: *mut LanceDataCacheStatistics, ++) -> Result { ++ if dataset.is_null() || out_statistics.is_null() { ++ return Err(lance_core::Error::invalid_input_source( ++ "dataset and out_statistics must not be NULL".into(), ++ )); ++ } ++ let dataset = unsafe { &*dataset }; ++ let statistics = dataset ++ .data_cache ++ .as_ref() ++ .map_or_else(LanceDataCacheStatistics::default, |cache| cache.snapshot()); ++ unsafe { ++ std::ptr::write_unaligned(out_statistics, statistics); ++ } ++ Ok(0) ++} +diff --git a/src/dataset.rs b/src/dataset.rs +index cc1f87c..76fd39e 100644 +--- a/src/dataset.rs ++++ b/src/dataset.rs +@@ -14,6 +14,7 @@ use lance::Dataset; + use lance::dataset::builder::DatasetBuilder; + use lance_core::Result; + ++use crate::data_cache::DatasetDataCache; + use crate::error::{ffi_try, swallow_unwind}; + use crate::helpers; + use crate::runtime::block_on; +@@ -23,6 +24,7 @@ use crate::stream_guard::guarded_ffi_stream_from_reader; + /// Opaque handle representing an opened Lance dataset. + pub struct LanceDataset { + pub(crate) inner: RwLock>, ++ pub(crate) data_cache: Option>, + } + + impl LanceDataset { +@@ -182,8 +184,16 @@ unsafe fn open_dataset_inner( + } + + let dataset = block_on(builder.load())?; ++ let (dataset, data_cache) = ++ if let Some(factory) = session.and_then(|session| session.data_cache_factory.clone()) { ++ let (dataset, data_cache) = factory.attach(dataset); ++ (dataset, Some(data_cache)) ++ } else { ++ (dataset, None) ++ }; + let handle = LanceDataset { + inner: RwLock::new(Arc::new(dataset)), ++ data_cache, + }; + Ok(Box::into_raw(Box::new(handle))) + } +@@ -519,6 +529,7 @@ mod tests { + .unwrap(); + let handle = LanceDataset { + inner: RwLock::new(Arc::new(dataset)), ++ data_cache: None, + }; + (tmp, handle) + } +diff --git a/src/foyer_data_cache.rs b/src/foyer_data_cache.rs +new file mode 100644 +index 0000000..32cc241 +--- /dev/null ++++ b/src/foyer_data_cache.rs +@@ -0,0 +1,950 @@ ++// SPDX-License-Identifier: Apache-2.0 ++// SPDX-FileCopyrightText: Copyright The Lance Authors ++ ++//! Foyer-backed cache for immutable Lance data-file batched range reads. ++ ++use std::collections::{BTreeMap, HashMap}; ++use std::ffi::c_char; ++use std::fmt::{Debug, Display, Formatter}; ++use std::ops::Range; ++use std::path::Path as FsPath; ++use std::sync::atomic::{AtomicU64, Ordering}; ++use std::sync::{Arc, Mutex, Weak}; ++ ++use async_trait::async_trait; ++use bytes::{Bytes, BytesMut}; ++use foyer::{ ++ BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCache, HybridCacheBuilder, ++ HybridCachePolicy, PsyncIoEngineConfig, ++}; ++use futures::stream::BoxStream; ++use lance_io::object_store::WrappingObjectStore; ++use object_store::path::Path; ++use object_store::{ ++ CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, ++ ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions, Result, ++}; ++ ++use crate::data_cache::{DataCacheFactory, DatasetDataCache, LanceDataCacheStatistics}; ++use crate::error::ffi_try; ++use crate::helpers; ++use crate::runtime::block_on; ++use crate::session::{LanceSession, session_new_with_data_cache_factory}; ++ ++const CACHE_KEY_VERSION: &str = "lance-data-v1"; ++const FOYER_PAGE_SIZE: usize = 4096; ++ ++/// Configuration for the optional Foyer data-file cache. ++#[repr(C)] ++#[derive(Clone, Copy, Debug)] ++pub struct LanceDataCacheOptions { ++ /// UTF-8 directory used by Foyer for persistent cache storage. ++ pub directory: *const c_char, ++ /// Maximum data bytes retained by Foyer's in-memory tier. ++ pub memory_capacity_bytes: u64, ++ /// Maximum bytes retained by Foyer's disk tier. ++ pub disk_capacity_bytes: u64, ++ /// Read/cache unit. Must be a non-zero multiple of 4096. ++ pub read_block_size_bytes: u64, ++} ++ ++/// Create a shared Lance session with a Foyer cache for immutable data-file ++/// batched range reads. ++/// ++/// `data_cache_options` and its `directory` field must not be NULL. The ++/// pointed-to values are copied before this function returns. ++#[unsafe(no_mangle)] ++pub unsafe extern "C" fn lance_session_new_with_data_cache( ++ index_cache_size_bytes: u64, ++ metadata_cache_size_bytes: u64, ++ data_cache_options: *const LanceDataCacheOptions, ++) -> *mut LanceSession { ++ ffi_try!( ++ unsafe { ++ session_new_with_data_cache_inner( ++ index_cache_size_bytes, ++ metadata_cache_size_bytes, ++ data_cache_options, ++ ) ++ }, ++ null ++ ) ++} ++ ++unsafe fn session_new_with_data_cache_inner( ++ index_cache_size_bytes: u64, ++ metadata_cache_size_bytes: u64, ++ data_cache_options: *const LanceDataCacheOptions, ++) -> lance_core::Result<*mut LanceSession> { ++ if data_cache_options.is_null() { ++ return Err(lance_core::Error::invalid_input_source( ++ "data_cache_options must not be NULL".into(), ++ )); ++ } ++ let options = unsafe { &*data_cache_options }; ++ let directory = unsafe { helpers::parse_c_string(options.directory)? }.ok_or_else(|| { ++ lance_core::Error::invalid_input_source( ++ "data_cache_options.directory must not be NULL".into(), ++ ) ++ })?; ++ if directory.is_empty() { ++ return Err(lance_core::Error::invalid_input_source( ++ "data_cache_options.directory must not be empty".into(), ++ )); ++ } ++ ++ let memory_capacity = u64_to_usize(options.memory_capacity_bytes, "memory_capacity_bytes")?; ++ let disk_capacity = u64_to_usize(options.disk_capacity_bytes, "disk_capacity_bytes")?; ++ let read_block_size = u64_to_usize(options.read_block_size_bytes, "read_block_size_bytes")?; ++ validate_data_cache_sizes(memory_capacity, disk_capacity, read_block_size)?; ++ ++ let data_cache = block_on(FoyerDataCache::try_new( ++ FsPath::new(&directory), ++ memory_capacity, ++ disk_capacity, ++ read_block_size, ++ )) ++ .map_err(|error| { ++ lance_core::Error::io(format!( ++ "failed to initialize Foyer data cache at {directory:?}: {error}" ++ )) ++ })?; ++ session_new_with_data_cache_factory( ++ index_cache_size_bytes, ++ metadata_cache_size_bytes, ++ Some(Arc::new(data_cache)), ++ ) ++} ++ ++fn validate_data_cache_sizes( ++ memory_capacity: usize, ++ disk_capacity: usize, ++ read_block_size: usize, ++) -> lance_core::Result<()> { ++ if read_block_size == 0 || !read_block_size.is_multiple_of(FOYER_PAGE_SIZE) { ++ return Err(lance_core::Error::invalid_input_source( ++ format!( ++ "read_block_size_bytes={read_block_size} must be a non-zero multiple of {FOYER_PAGE_SIZE}" ++ ) ++ .into(), ++ )); ++ } ++ if memory_capacity < read_block_size { ++ return Err(lance_core::Error::invalid_input_source( ++ format!( ++ "memory_capacity_bytes={memory_capacity} must be at least read_block_size_bytes={read_block_size}" ++ ) ++ .into(), ++ )); ++ } ++ let minimum_disk_capacity = read_block_size.checked_mul(2).ok_or_else(|| { ++ lance_core::Error::invalid_input_source( ++ format!("read_block_size_bytes={read_block_size} is too large").into(), ++ ) ++ })?; ++ if !disk_capacity.is_multiple_of(FOYER_PAGE_SIZE) || disk_capacity < minimum_disk_capacity { ++ return Err(lance_core::Error::invalid_input_source( ++ format!( ++ "disk_capacity_bytes={disk_capacity} must be a multiple of {FOYER_PAGE_SIZE} and at least twice read_block_size_bytes={read_block_size}" ++ ) ++ .into(), ++ )); ++ } ++ Ok(()) ++} ++ ++fn u64_to_usize(value: u64, field: &'static str) -> lance_core::Result { ++ usize::try_from(value).map_err(|_| { ++ lance_core::Error::invalid_input_source( ++ format!("{field}={value} exceeds usize::MAX on this target").into(), ++ ) ++ }) ++} ++ ++/// Process-local owner of a Foyer hybrid cache. ++#[derive(Clone)] ++pub(crate) struct FoyerDataCache { ++ cache: HybridCache, ++ read_block_size: usize, ++ wrapped_stores: Arc>>, ++} ++ ++struct WrappedStore { ++ wrapper: Weak, ++ origin: Weak, ++} ++ ++impl Debug for FoyerDataCache { ++ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { ++ f.debug_struct("FoyerDataCache") ++ .field("read_block_size", &self.read_block_size) ++ .finish_non_exhaustive() ++ } ++} ++ ++impl FoyerDataCache { ++ pub(crate) async fn try_new( ++ directory: &FsPath, ++ memory_capacity: usize, ++ disk_capacity: usize, ++ read_block_size: usize, ++ ) -> std::result::Result { ++ let engine_block_size = read_block_size ++ .checked_mul(2) ++ .ok_or_else(|| foyer::Error::new(foyer::ErrorKind::Config, "read block size overflow"))? ++ .max(FOYER_PAGE_SIZE); ++ let memory_shards = (memory_capacity / read_block_size).clamp(1, 8); ++ let device = FsDeviceBuilder::new(directory) ++ .with_capacity(disk_capacity) ++ .build()?; ++ let engine = BlockEngineConfig::new(device).with_block_size(engine_block_size); ++ let cache = HybridCacheBuilder::new() ++ .with_name("lance_data") ++ .with_policy(HybridCachePolicy::WriteOnInsertion) ++ // Entries are already sent to storage on insertion. Avoid making ++ // the last Dataset drop wait for a final full-memory flush. ++ .with_flush_on_close(false) ++ .memory(memory_capacity) ++ .with_shards(memory_shards) ++ .with_weighter(|_key: &String, value: &Bytes| value.len().max(1)) ++ .storage() ++ .with_io_engine_config(PsyncIoEngineConfig::new()) ++ .with_engine_config(engine) ++ .build() ++ .await?; ++ Ok(Self { ++ cache, ++ read_block_size, ++ wrapped_stores: Arc::new(Mutex::new(HashMap::new())), ++ }) ++ } ++ ++ fn is_cacheable_data_file(location: &Path) -> bool { ++ let mut parts = location.as_ref().rsplit('/'); ++ matches!( ++ (parts.next(), parts.next()), ++ (Some(file), Some("data")) if file.ends_with(".lance") ++ ) ++ } ++ ++ fn key(&self, store_prefix: &str, location: &Path, block_index: u64) -> String { ++ format!( ++ "{CACHE_KEY_VERSION}\0{}\0{store_prefix}\0{}\0{block_index}", ++ self.read_block_size, ++ location.as_ref() ++ ) ++ } ++ ++ fn size_key(&self, store_prefix: &str, location: &Path) -> String { ++ format!( ++ "{CACHE_KEY_VERSION}\0{}\0{store_prefix}\0{}\0size", ++ self.read_block_size, ++ location.as_ref() ++ ) ++ } ++ ++ fn create_scope(&self) -> Arc { ++ Arc::new(DatasetFoyerDataCache { ++ cache: self.clone(), ++ statistics: Arc::new(FoyerDataCacheStatistics::default()), ++ }) ++ } ++ ++ fn unwrap_store(&self, store: Arc) -> Arc { ++ let identity = Arc::as_ptr(&store) as *const () as usize; ++ let origin = { ++ let mut wrapped_stores = self ++ .wrapped_stores ++ .lock() ++ .unwrap_or_else(|error| error.into_inner()); ++ let origin = wrapped_stores.get(&identity).and_then(|entry| { ++ let wrapper = entry.wrapper.upgrade()?; ++ if Arc::ptr_eq(&wrapper, &store) { ++ entry.origin.upgrade() ++ } else { ++ None ++ } ++ }); ++ if origin.is_none() { ++ wrapped_stores.remove(&identity); ++ } ++ origin ++ }; ++ match origin { ++ Some(origin) => origin, ++ None => store, ++ } ++ } ++ ++ fn remember_wrapper(&self, wrapper: &Arc, origin: &Arc) { ++ let identity = Arc::as_ptr(wrapper) as *const () as usize; ++ self.wrapped_stores ++ .lock() ++ .unwrap_or_else(|error| error.into_inner()) ++ .insert( ++ identity, ++ WrappedStore { ++ wrapper: Arc::downgrade(wrapper), ++ origin: Arc::downgrade(origin), ++ }, ++ ); ++ } ++ ++ fn forget_wrapper(&self, identity: usize) { ++ self.wrapped_stores ++ .lock() ++ .unwrap_or_else(|error| error.into_inner()) ++ .remove(&identity); ++ } ++} ++ ++#[derive(Debug, Default)] ++struct FoyerDataCacheStatistics { ++ bytes_read_from_cache: AtomicU64, ++ bytes_read_from_remote: AtomicU64, ++} ++ ++impl FoyerDataCacheStatistics { ++ fn record(&self, bytes_read_from_cache: u64, bytes_read_from_remote: u64) { ++ self.bytes_read_from_cache ++ .fetch_add(bytes_read_from_cache, Ordering::Relaxed); ++ self.bytes_read_from_remote ++ .fetch_add(bytes_read_from_remote, Ordering::Relaxed); ++ } ++ ++ fn snapshot(&self) -> LanceDataCacheStatistics { ++ LanceDataCacheStatistics { ++ bytes_read_from_cache: self.bytes_read_from_cache.load(Ordering::Relaxed), ++ bytes_read_from_remote: self.bytes_read_from_remote.load(Ordering::Relaxed), ++ } ++ } ++} ++ ++impl DataCacheFactory for FoyerDataCache { ++ fn attach(&self, dataset: lance::Dataset) -> (lance::Dataset, Arc) { ++ let scope = self.create_scope(); ++ let wrapper: Arc = scope.clone(); ++ let dataset = dataset.with_object_store_wrappers([wrapper]); ++ (dataset, scope) ++ } ++} ++ ++#[derive(Debug)] ++struct DatasetFoyerDataCache { ++ cache: FoyerDataCache, ++ statistics: Arc, ++} ++ ++impl DatasetDataCache for DatasetFoyerDataCache { ++ fn snapshot(&self) -> LanceDataCacheStatistics { ++ self.statistics.snapshot() ++ } ++ ++ fn attach_fresh(&self, dataset: lance::Dataset) -> (lance::Dataset, Arc) { ++ self.cache.attach(dataset) ++ } ++} ++ ++impl WrappingObjectStore for DatasetFoyerDataCache { ++ fn wrap(&self, store_prefix: &str, original: Arc) -> Arc { ++ // A derived Dataset can already contain this cache wrapper. Resolve ++ // that exact wrapper back to its origin before attaching fresh ++ // dataset-scoped counters. ++ let original = self.cache.unwrap_store(original); ++ let cached_store = ++ Arc::new_cyclic(|weak: &Weak| DataCacheObjectStore { ++ cache: self.cache.clone(), ++ store_prefix: store_prefix.to_owned(), ++ original: original.clone(), ++ statistics: self.statistics.clone(), ++ identity: weak.as_ptr() as usize, ++ }); ++ let wrapped: Arc = cached_store.clone(); ++ self.cache.remember_wrapper(&wrapped, &original); ++ wrapped ++ } ++} ++ ++#[derive(Debug)] ++struct DataCacheObjectStore { ++ cache: FoyerDataCache, ++ store_prefix: String, ++ original: Arc, ++ statistics: Arc, ++ identity: usize, ++} ++ ++impl Drop for DataCacheObjectStore { ++ fn drop(&mut self) { ++ self.cache.forget_wrapper(self.identity); ++ } ++} ++ ++impl Display for DataCacheObjectStore { ++ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { ++ write!(f, "FoyerDataCache({})", self.original) ++ } ++} ++ ++impl DataCacheObjectStore { ++ async fn read_origin_ranges( ++ &self, ++ location: &Path, ++ ranges: &[Range], ++ ) -> Result> { ++ let bytes = self.original.get_ranges(location, ranges).await?; ++ self.statistics.record(0, total_bytes(&bytes)); ++ Ok(bytes) ++ } ++ ++ async fn object_size(&self, location: &Path) -> Result { ++ let key = self.cache.size_key(&self.store_prefix, location); ++ match self.cache.cache.get(&key).await { ++ Ok(Some(entry)) => match entry.value().as_ref().try_into() { ++ Ok(bytes) => return Ok(u64::from_le_bytes(bytes)), ++ Err(_) => log::warn!( ++ "Foyer data-cache size entry was malformed for {location}; refreshing it" ++ ), ++ }, ++ Ok(None) => {} ++ Err(error) => { ++ log::warn!("Foyer data-cache size lookup failed for {location}: {error}"); ++ } ++ } ++ ++ let size = self.original.head(location).await?.size; ++ self.cache ++ .cache ++ .insert(key, Bytes::copy_from_slice(&size.to_le_bytes())); ++ Ok(size) ++ } ++ ++ async fn cached_ranges(&self, location: &Path, ranges: &[Range]) -> Result> { ++ if ranges.is_empty() { ++ return Ok(Vec::new()); ++ } ++ if ranges.iter().any(|range| range.start >= range.end) { ++ return self.read_origin_ranges(location, ranges).await; ++ } ++ ++ let object_size = self.object_size(location).await?; ++ if ranges.iter().any(|range| range.start >= object_size) { ++ // Preserve the origin's exact error for ranges that start at or ++ // beyond EOF. ++ return self.read_origin_ranges(location, ranges).await; ++ } ++ let readable_ranges = ranges ++ .iter() ++ .map(|range| range.start..range.end.min(object_size)) ++ .collect::>(); ++ ++ let block_size = self.cache.read_block_size as u64; ++ let mut blocks = BTreeMap::>::new(); ++ for range in &readable_ranges { ++ let first = range.start / block_size; ++ let last = (range.end - 1) / block_size; ++ for block_index in first..=last { ++ blocks.entry(block_index).or_default(); ++ } ++ } ++ ++ for (block_index, block) in &mut blocks { ++ let key = self.cache.key(&self.store_prefix, location, *block_index); ++ match self.cache.cache.get(&key).await { ++ Ok(Some(entry)) => *block = Some(entry.value().clone()), ++ Ok(None) => {} ++ Err(error) => { ++ // Cache availability must not affect query correctness. ++ log::warn!("Foyer data-cache lookup failed for {location}: {error}"); ++ } ++ } ++ } ++ ++ let (bytes_read_from_cache, bytes_read_from_remote) = ++ requested_bytes_by_cache_status(&readable_ranges, block_size, &blocks); ++ let missing: Vec = blocks ++ .iter() ++ .filter_map(|(block_index, value)| value.is_none().then_some(*block_index)) ++ .collect(); ++ let mut runs = Vec::>::new(); ++ for block_index in missing { ++ let start = block_index ++ .checked_mul(block_size) ++ .ok_or_else(|| cache_error("data-cache block offset overflow"))?; ++ let end = start ++ .checked_add(block_size) ++ .ok_or_else(|| cache_error("data-cache block end overflow"))? ++ .min(object_size); ++ match runs.last_mut() { ++ Some(run) if run.end == start => run.end = end, ++ _ => runs.push(start..end), ++ } ++ } ++ ++ if !runs.is_empty() { ++ // Fetch all contiguous miss runs together so a large Lance read is ++ // not expanded into one remote request per cache block. ++ let fetched = self.original.get_ranges(location, &runs).await?; ++ for (run, bytes) in runs.into_iter().zip(fetched) { ++ let first_block = run.start / block_size; ++ for (offset, chunk) in bytes.chunks(self.cache.read_block_size).enumerate() { ++ let block_index = first_block + offset as u64; ++ let value = Bytes::copy_from_slice(chunk); ++ let key = self.cache.key(&self.store_prefix, location, block_index); ++ self.cache.cache.insert(key, value.clone()); ++ if let Some(block) = blocks.get_mut(&block_index) { ++ *block = Some(value); ++ } ++ } ++ } ++ } ++ ++ let assembled = readable_ranges ++ .iter() ++ .map(|range| assemble_range(location, range, block_size, &blocks)) ++ .collect::>>(); ++ match assembled { ++ Ok(bytes) => { ++ self.statistics ++ .record(bytes_read_from_cache, bytes_read_from_remote); ++ Ok(bytes) ++ } ++ Err(error) => { ++ // A malformed or incomplete cached entry must never turn a ++ // valid source read into a query failure. ++ log::warn!( ++ "Foyer data-cache entry was unusable for {location}; bypassing cache: {error}" ++ ); ++ let bytes = self.original.get_ranges(location, ranges).await?; ++ self.statistics.record(0, total_bytes(&bytes)); ++ Ok(bytes) ++ } ++ } ++ } ++} ++ ++fn total_bytes(ranges: &[Bytes]) -> u64 { ++ ranges.iter().fold(0_u64, |total, bytes| { ++ total.saturating_add(bytes.len() as u64) ++ }) ++} ++ ++fn requested_bytes_by_cache_status( ++ ranges: &[Range], ++ block_size: u64, ++ blocks: &BTreeMap>, ++) -> (u64, u64) { ++ let mut hit_bytes = 0_u64; ++ let mut miss_bytes = 0_u64; ++ for range in ranges { ++ let mut start = range.start; ++ while start < range.end { ++ let block_index = start / block_size; ++ let block_start = block_index * block_size; ++ let end = range.end.min(block_start.saturating_add(block_size)); ++ let bytes = end - start; ++ if blocks ++ .get(&block_index) ++ .is_some_and(|block| block.is_some()) ++ { ++ hit_bytes = hit_bytes.saturating_add(bytes); ++ } else { ++ miss_bytes = miss_bytes.saturating_add(bytes); ++ } ++ start = end; ++ } ++ } ++ (hit_bytes, miss_bytes) ++} ++ ++fn assemble_range( ++ location: &Path, ++ range: &Range, ++ block_size: u64, ++ blocks: &BTreeMap>, ++) -> Result { ++ if range.is_empty() { ++ return Ok(Bytes::new()); ++ } ++ let first = range.start / block_size; ++ let last = (range.end - 1) / block_size; ++ if first == last { ++ let block = blocks ++ .get(&first) ++ .and_then(Option::as_ref) ++ .ok_or_else(|| cache_error(format!("missing block {first} for {location}")))?; ++ let block_start = first * block_size; ++ let start = usize::try_from(range.start - block_start) ++ .map_err(|_| cache_error("data-cache slice start exceeds usize::MAX"))?; ++ let end = usize::try_from((range.end - block_start).min(block_size)) ++ .map_err(|_| cache_error("data-cache slice end exceeds usize::MAX"))? ++ .min(block.len()); ++ if start >= block.len() { ++ return Err(cache_error(format!( ++ "short data-cache block {first} for {location}: need {start}..{end}, got {} bytes", ++ block.len() ++ ))); ++ } ++ return Ok(block.slice(start..end)); ++ } ++ ++ let requested_len = usize::try_from(range.end - range.start) ++ .map_err(|_| cache_error(format!("range {range:?} for {location} exceeds usize::MAX")))?; ++ let mut output = BytesMut::with_capacity(requested_len); ++ for block_index in first..=last { ++ let block = blocks ++ .get(&block_index) ++ .and_then(Option::as_ref) ++ .ok_or_else(|| cache_error(format!("missing block {block_index} for {location}")))?; ++ let block_start = block_index * block_size; ++ let start = usize::try_from(range.start.saturating_sub(block_start)) ++ .map_err(|_| cache_error("data-cache slice start exceeds usize::MAX"))?; ++ let end_in_block = range.end.saturating_sub(block_start).min(block_size); ++ let end = usize::try_from(end_in_block) ++ .map_err(|_| cache_error("data-cache slice end exceeds usize::MAX"))?; ++ if start >= block.len() { ++ if !output.is_empty() { ++ break; ++ } ++ return Err(cache_error(format!( ++ "short data-cache block {block_index} for {location}: need {start}..{end}, got {} bytes", ++ block.len() ++ ))); ++ } ++ let actual_end = end.min(block.len()); ++ output.extend_from_slice(&block[start..actual_end]); ++ if actual_end < end { ++ break; ++ } ++ } ++ Ok(output.freeze()) ++} ++ ++fn cache_error(message: impl Into) -> object_store::Error { ++ object_store::Error::Generic { ++ store: "foyer_data_cache", ++ source: Box::new(std::io::Error::other(message.into())), ++ } ++} ++ ++#[async_trait] ++#[deny(clippy::missing_trait_methods)] ++impl ObjectStore for DataCacheObjectStore { ++ async fn put_opts( ++ &self, ++ location: &Path, ++ payload: PutPayload, ++ opts: PutOptions, ++ ) -> Result { ++ self.original.put_opts(location, payload, opts).await ++ } ++ ++ async fn put_multipart_opts( ++ &self, ++ location: &Path, ++ opts: PutMultipartOptions, ++ ) -> Result> { ++ self.original.put_multipart_opts(location, opts).await ++ } ++ ++ async fn get_opts(&self, location: &Path, options: GetOptions) -> Result { ++ self.original.get_opts(location, options).await ++ } ++ ++ async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> Result> { ++ if FoyerDataCache::is_cacheable_data_file(location) { ++ self.cached_ranges(location, ranges).await ++ } else { ++ self.original.get_ranges(location, ranges).await ++ } ++ } ++ ++ fn delete_stream( ++ &self, ++ locations: BoxStream<'static, Result>, ++ ) -> BoxStream<'static, Result> { ++ self.original.delete_stream(locations) ++ } ++ ++ fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result> { ++ self.original.list(prefix) ++ } ++ ++ fn list_with_offset( ++ &self, ++ prefix: Option<&Path>, ++ offset: &Path, ++ ) -> BoxStream<'static, Result> { ++ self.original.list_with_offset(prefix, offset) ++ } ++ ++ async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result { ++ self.original.list_with_delimiter(prefix).await ++ } ++ ++ async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> { ++ self.original.copy_opts(from, to, options).await ++ } ++ ++ async fn rename_opts(&self, from: &Path, to: &Path, options: RenameOptions) -> Result<()> { ++ self.original.rename_opts(from, to, options).await ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use std::sync::mpsc; ++ use std::time::Duration; ++ ++ use lance_io::object_store::ChainedWrappingObjectStore; ++ use object_store::memory::InMemory; ++ ++ use super::*; ++ ++ fn wrap_for_test( ++ cache: &FoyerDataCache, ++ original: Arc, ++ ) -> (Arc, Arc) { ++ let scope = cache.create_scope(); ++ (scope.wrap("memory://test", original), scope) ++ } ++ ++ #[tokio::test] ++ async fn caches_only_immutable_data_file_ranges() { ++ let directory = tempfile::tempdir().unwrap(); ++ let cache = FoyerDataCache::try_new(directory.path(), 256 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data_path = Path::from("table.lance/data/part-0.lance"); ++ let second_data_path = Path::from("table.lance/data/part-1.lance"); ++ let manifest_path = Path::from("table.lance/_versions/1.manifest"); ++ let data = Bytes::from((0..200_000).map(|value| value as u8).collect::>()); ++ let second_data = Bytes::from_static(b"second fragment"); ++ original.put(&data_path, data.clone().into()).await.unwrap(); ++ original ++ .put(&second_data_path, second_data.clone().into()) ++ .await ++ .unwrap(); ++ original ++ .put(&manifest_path, Bytes::from_static(b"manifest").into()) ++ .await ++ .unwrap(); ++ ++ let (wrapped, statistics) = wrap_for_test(&cache, original.clone()); ++ let ranges = vec![10..90_000, 65_000..140_000, 190_000..220_000]; ++ let second_data_range = 0..15; ++ let first = wrapped.get_ranges(&data_path, &ranges).await.unwrap(); ++ assert_eq!(first[0], data.slice(10..90_000)); ++ assert_eq!(first[1], data.slice(65_000..140_000)); ++ assert_eq!(first[2], data.slice(190_000..200_000)); ++ assert_eq!( ++ wrapped ++ .get_ranges(&second_data_path, std::slice::from_ref(&second_data_range)) ++ .await ++ .unwrap(), ++ vec![second_data.clone()] ++ ); ++ ++ assert_eq!( ++ statistics.snapshot(), ++ LanceDataCacheStatistics { ++ bytes_read_from_cache: 0, ++ bytes_read_from_remote: 175_005, ++ } ++ ); ++ ++ original.delete(&data_path).await.unwrap(); ++ original.delete(&second_data_path).await.unwrap(); ++ let second = wrapped.get_ranges(&data_path, &ranges).await.unwrap(); ++ assert_eq!(second, first); ++ assert_eq!( ++ wrapped ++ .get_ranges(&second_data_path, std::slice::from_ref(&second_data_range)) ++ .await ++ .unwrap(), ++ vec![second_data] ++ ); ++ ++ assert_eq!( ++ statistics.snapshot(), ++ LanceDataCacheStatistics { ++ bytes_read_from_cache: 175_005, ++ bytes_read_from_remote: 175_005, ++ } ++ ); ++ ++ assert_eq!( ++ wrapped.get_range(&manifest_path, 0..8).await.unwrap(), ++ Bytes::from_static(b"manifest") ++ ); ++ original.delete(&manifest_path).await.unwrap(); ++ assert!(wrapped.get_range(&manifest_path, 0..8).await.is_err()); ++ } ++ ++ #[tokio::test] ++ async fn recovers_cached_data_from_disk() { ++ let directory = tempfile::tempdir().unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data_path = Path::from("table.lance/data/part-0.lance"); ++ let data = Bytes::from((0..100_000).map(|value| value as u8).collect::>()); ++ original.put(&data_path, data.clone().into()).await.unwrap(); ++ ++ let cache = FoyerDataCache::try_new(directory.path(), 128 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let (wrapped, _) = wrap_for_test(&cache, original.clone()); ++ let requested_range = 10..90_000; ++ assert_eq!( ++ wrapped ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(), ++ vec![data.slice(10..90_000)] ++ ); ++ drop(wrapped); ++ cache.cache.close().await.unwrap(); ++ drop(cache); ++ ++ original.delete(&data_path).await.unwrap(); ++ let recovered = ++ FoyerDataCache::try_new(directory.path(), 128 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let (wrapped, statistics) = wrap_for_test(&recovered, original); ++ assert_eq!( ++ wrapped ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(), ++ vec![data.slice(10..90_000)] ++ ); ++ assert_eq!(statistics.snapshot().bytes_read_from_cache, 89_990); ++ assert_eq!(statistics.snapshot().bytes_read_from_remote, 0); ++ } ++ ++ #[tokio::test] ++ async fn dataset_scopes_share_cache_without_sharing_statistics() { ++ let directory = tempfile::tempdir().unwrap(); ++ let cache = FoyerDataCache::try_new(directory.path(), 128 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data_path = Path::from("table.lance/data/part-0.lance"); ++ let data = Bytes::from((0..100_000).map(|value| value as u8).collect::>()); ++ original.put(&data_path, data.clone().into()).await.unwrap(); ++ ++ let source_scope = cache.create_scope(); ++ let source_store = source_scope.wrap("memory://test", original.clone()); ++ ++ // A restored Dataset is derived from an already-wrapped source ++ // Dataset. The fresh scope must unwrap to the registered origin rather ++ // than nesting over the source scope. ++ let restored_scope = cache.create_scope(); ++ let restored_store = restored_scope.wrap("memory://test", source_store.clone()); ++ let requested_range = 10..90_000; ++ assert_eq!( ++ restored_store ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(), ++ vec![data.slice(10..90_000)] ++ ); ++ assert_eq!(source_scope.snapshot(), LanceDataCacheStatistics::default()); ++ assert_eq!(restored_scope.snapshot().bytes_read_from_remote, 89_990); ++ ++ original.delete(&data_path).await.unwrap(); ++ assert_eq!( ++ source_store ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(), ++ vec![data.slice(10..90_000)] ++ ); ++ assert_eq!(source_scope.snapshot().bytes_read_from_cache, 89_990); ++ assert_eq!(restored_scope.snapshot().bytes_read_from_remote, 89_990); ++ ++ drop(restored_store); ++ drop(source_store); ++ assert!(cache.wrapped_stores.lock().unwrap().is_empty()); ++ } ++ ++ #[tokio::test] ++ async fn chained_scopes_drop_intermediate_store_without_deadlocking() { ++ let directory = tempfile::tempdir().unwrap(); ++ let cache = FoyerDataCache::try_new(directory.path(), 128 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let first: Arc = cache.create_scope(); ++ let second: Arc = cache.create_scope(); ++ let chained = ChainedWrappingObjectStore::new(vec![first, second]); ++ let original: Arc = Arc::new(InMemory::new()); ++ let (sender, receiver) = mpsc::channel(); ++ ++ let thread = std::thread::spawn(move || { ++ sender ++ .send(chained.wrap("memory://test", original)) ++ .unwrap(); ++ }); ++ let wrapped = receiver ++ .recv_timeout(Duration::from_secs(2)) ++ .expect("chained cache wrappers deadlocked while dropping the intermediate store"); ++ thread.join().unwrap(); ++ ++ assert_eq!(cache.wrapped_stores.lock().unwrap().len(), 1); ++ drop(wrapped); ++ assert!(cache.wrapped_stores.lock().unwrap().is_empty()); ++ } ++ ++ #[tokio::test] ++ async fn truncates_ranges_at_eof_before_enumerating_cache_blocks() { ++ let directory = tempfile::tempdir().unwrap(); ++ let cache = FoyerDataCache::try_new(directory.path(), 128 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data_path = Path::from("table.lance/data/part-0.lance"); ++ let data = Bytes::from((0..100_000).map(|value| value as u8).collect::>()); ++ original.put(&data_path, data.into()).await.unwrap(); ++ ++ let requested_range = 99_990..300_000; ++ let expected = original ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(); ++ let (wrapped, statistics) = wrap_for_test(&cache, original.clone()); ++ let actual = wrapped ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(); ++ assert_eq!(actual, expected); ++ assert_eq!(statistics.snapshot().bytes_read_from_remote, 10); ++ ++ original.delete(&data_path).await.unwrap(); ++ assert_eq!( ++ wrapped ++ .get_ranges(&data_path, std::slice::from_ref(&requested_range)) ++ .await ++ .unwrap(), ++ expected ++ ); ++ assert_eq!(statistics.snapshot().bytes_read_from_cache, 10); ++ assert_eq!(statistics.snapshot().bytes_read_from_remote, 10); ++ } ++ ++ #[test] ++ fn recognizes_only_direct_data_children() { ++ assert!(FoyerDataCache::is_cacheable_data_file(&Path::from( ++ "dataset/data/part.lance" ++ ))); ++ assert!(!FoyerDataCache::is_cacheable_data_file(&Path::from( ++ "dataset/data/nested/part.lance" ++ ))); ++ assert!(!FoyerDataCache::is_cacheable_data_file(&Path::from( ++ "dataset/indices/index.lance" ++ ))); ++ assert!(!FoyerDataCache::is_cacheable_data_file(&Path::from( ++ "dataset/_versions/1.manifest" ++ ))); ++ } ++} +diff --git a/src/lib.rs b/src/lib.rs +index 8b212f5..923528a 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -25,11 +25,13 @@ mod alter_columns; + mod async_dispatcher; + mod batch; + mod compact; ++mod data_cache; + mod data_statistics; + mod dataset; + mod delete; + mod drop_columns; + mod error; ++mod foyer_data_cache; + mod fragment_writer; + mod fts_query; + mod helpers; +@@ -51,6 +53,7 @@ pub use add_columns::*; + pub use alter_columns::*; + pub use batch::*; + pub use compact::*; ++pub use data_cache::{LanceDataCacheStatistics, lance_dataset_get_data_cache_statistics}; + pub use data_statistics::*; + pub use dataset::*; + pub use delete::*; +@@ -58,6 +61,7 @@ pub use drop_columns::*; + pub use error::{ + LanceErrorCode, lance_free_string, lance_last_error_code, lance_last_error_message, + }; ++pub use foyer_data_cache::{LanceDataCacheOptions, lance_session_new_with_data_cache}; + pub use fragment_writer::*; + pub use fts_query::*; + pub use index::*; +diff --git a/src/restore.rs b/src/restore.rs +index 7804b55..fa2d26d 100644 +--- a/src/restore.rs ++++ b/src/restore.rs +@@ -65,8 +65,16 @@ unsafe fn restore_inner(dataset: *const LanceDataset, version: u64) -> Result<*m + Ok::<_, lance_core::Error>(checked_out) + })?; + ++ let (restored, data_cache) = if let Some(data_cache) = &ds.data_cache { ++ let (restored, data_cache) = data_cache.attach_fresh(restored); ++ (restored, Some(data_cache)) ++ } else { ++ (restored, None) ++ }; ++ + let handle = LanceDataset { + inner: RwLock::new(Arc::new(restored)), ++ data_cache, + }; + Ok(Box::into_raw(Box::new(handle))) + } +diff --git a/src/session.rs b/src/session.rs +index 60a1623..9ed8cfe 100644 +--- a/src/session.rs ++++ b/src/session.rs +@@ -8,12 +8,14 @@ use std::sync::Arc; + use lance::session::Session; + use lance_core::Result; + ++use crate::data_cache::DataCacheFactory; + use crate::error::{ffi_try, swallow_unwind}; + use crate::runtime::block_on; + +-/// Opaque handle for sharing Lance metadata and index caches across datasets. ++/// Opaque handle for shared Lance caches across datasets. + pub struct LanceSession { + pub(crate) inner: Arc, ++ pub(crate) data_cache_factory: Option>, + } + + /// Snapshot of a session's metadata and index cache statistics. +@@ -47,6 +49,14 @@ pub extern "C" fn lance_session_new( + fn session_new_inner( + index_cache_size_bytes: u64, + metadata_cache_size_bytes: u64, ++) -> Result<*mut LanceSession> { ++ session_new_with_data_cache_factory(index_cache_size_bytes, metadata_cache_size_bytes, None) ++} ++ ++pub(crate) fn session_new_with_data_cache_factory( ++ index_cache_size_bytes: u64, ++ metadata_cache_size_bytes: u64, ++ data_cache_factory: Option>, + ) -> Result<*mut LanceSession> { + let index_cache_size_bytes = u64_to_usize(index_cache_size_bytes, "index_cache_size_bytes")?; + let metadata_cache_size_bytes = +@@ -58,6 +68,7 @@ fn session_new_inner( + ); + Ok(Box::into_raw(Box::new(LanceSession { + inner: Arc::new(session), ++ data_cache_factory, + }))) + } + +diff --git a/src/writer.rs b/src/writer.rs +index 1971510..ba51c87 100644 +--- a/src/writer.rs ++++ b/src/writer.rs +@@ -282,6 +282,7 @@ unsafe fn write_dataset_inner( + if !out_dataset.is_null() { + let handle = LanceDataset { + inner: RwLock::new(Arc::new(dataset)), ++ data_cache: None, + }; + // SAFETY: `out_dataset` is non-NULL (checked above) and the caller + // guarantees it points to caller-owned, writable storage of size +diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs +index 8805764..b4313f4 100644 +--- a/tests/c_api_test.rs ++++ b/tests/c_api_test.rs +@@ -96,10 +96,83 @@ fn create_large_dataset(num_rows: i32) -> (tempfile::TempDir, String) { + (tmp, uri) + } + ++/// Helper: create two fragments large enough for Lance's batched range-read ++/// path, which is the path wrapped by the Foyer data cache. ++fn create_large_multi_fragment_dataset(num_rows_per_fragment: i32) -> (tempfile::TempDir, String) { ++ let (tmp, uri) = create_large_dataset(num_rows_per_fragment); ++ let schema = Arc::new(Schema::new(vec![ ++ Field::new("id", DataType::Int32, false), ++ Field::new("value", DataType::Float32, true), ++ Field::new("label", DataType::Utf8, true), ++ ])); ++ let ids: Vec = (num_rows_per_fragment..2 * num_rows_per_fragment).collect(); ++ let values: Vec = ids.iter().map(|id| *id as f32 * 0.5).collect(); ++ let labels: Vec = ids.iter().map(|id| format!("row_{id}")).collect(); ++ let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); ++ let batch = RecordBatch::try_new( ++ schema.clone(), ++ vec![ ++ Arc::new(Int32Array::from(ids)), ++ Arc::new(Float32Array::from(values)), ++ Arc::new(StringArray::from(label_refs)), ++ ], ++ ) ++ .unwrap(); ++ ++ lance_c::runtime::block_on(async { ++ let mut dataset = Dataset::open(&uri).await.unwrap(); ++ dataset ++ .append( ++ arrow::record_batch::RecordBatchIterator::new(vec![Ok(batch)], schema), ++ None, ++ ) ++ .await ++ .unwrap(); ++ }); ++ ++ (tmp, uri) ++} ++ + fn c_str(s: &str) -> CString { + CString::new(s).unwrap() + } + ++fn file_object_store_uri(path: &str) -> CString { ++ let path = path.replace('\\', "/"); ++ let leading_slash = if path.starts_with('/') { "" } else { "/" }; ++ c_str(&format!("file-object-store://{leading_slash}{path}")) ++} ++ ++fn create_data_cache_session() -> (tempfile::TempDir, *mut LanceSession) { ++ let directory = tempfile::tempdir().unwrap(); ++ let c_directory = c_str(directory.path().to_str().unwrap()); ++ let options = LanceDataCacheOptions { ++ directory: c_directory.as_ptr(), ++ memory_capacity_bytes: 8 * 1024 * 1024, ++ disk_capacity_bytes: 32 * 1024 * 1024, ++ read_block_size_bytes: 64 * 1024, ++ }; ++ let session = unsafe { lance_session_new_with_data_cache(0, 16 * 1024 * 1024, &options) }; ++ assert!(!session.is_null(), "data-cache session should be created"); ++ (directory, session) ++} ++ ++fn data_cache_statistics(dataset: *const LanceDataset) -> LanceDataCacheStatistics { ++ let mut statistics = LanceDataCacheStatistics::default(); ++ assert_eq!( ++ unsafe { lance_dataset_get_data_cache_statistics(dataset, &mut statistics) }, ++ 0 ++ ); ++ statistics ++} ++ ++fn scanned_row_count(dataset: *const LanceDataset) -> usize { ++ scan_all_rows(dataset) ++ .iter() ++ .map(RecordBatch::num_rows) ++ .sum() ++} ++ + #[derive(Default)] + struct CapturedScanStatistics { + calls: usize, +@@ -313,6 +386,120 @@ fn test_shared_session_rejects_null_inputs() { + } + } + ++#[test] ++fn test_session_with_data_cache_serves_repeated_scan() { ++ let (tmp, uri) = create_large_multi_fragment_dataset(10_000); ++ let c_uri = file_object_store_uri(&uri); ++ let (_cache_directory, session) = create_data_cache_session(); ++ ++ let dataset = ++ unsafe { lance_dataset_open_with_session(c_uri.as_ptr(), ptr::null(), 0, session) }; ++ assert!(!dataset.is_null(), "dataset open should succeed"); ++ ++ assert_eq!(data_cache_statistics(dataset), Default::default()); ++ assert_eq!(scanned_row_count(dataset), 20_000); ++ let first_statistics = data_cache_statistics(dataset); ++ assert!(first_statistics.bytes_read_from_remote > 0); ++ ++ let cached_dataset = ++ unsafe { lance_dataset_open_with_session(c_uri.as_ptr(), ptr::null(), 0, session) }; ++ assert!( ++ !cached_dataset.is_null(), ++ "second dataset open should succeed" ++ ); ++ unsafe { lance_session_close(session) }; ++ ++ for entry in std::fs::read_dir(tmp.path().join("large_ds/data")).unwrap() { ++ std::fs::remove_file(entry.unwrap().path()).unwrap(); ++ } ++ assert_eq!(scanned_row_count(cached_dataset), 20_000); ++ let cached_statistics = data_cache_statistics(cached_dataset); ++ assert!(cached_statistics.bytes_read_from_cache > 0); ++ assert_eq!(cached_statistics.bytes_read_from_remote, 0); ++ ++ assert_eq!(data_cache_statistics(dataset), first_statistics); ++ ++ unsafe { lance_dataset_close(cached_dataset) }; ++ unsafe { lance_dataset_close(dataset) }; ++} ++ ++#[test] ++fn test_dataset_data_cache_statistics_validates_inputs_and_defaults_to_zero() { ++ let (_tmp, uri) = create_test_dataset(); ++ let c_uri = c_str(&uri); ++ let dataset = unsafe { lance_dataset_open(c_uri.as_ptr(), ptr::null(), 0) }; ++ assert!(!dataset.is_null()); ++ ++ let mut statistics = LanceDataCacheStatistics::default(); ++ assert_eq!( ++ unsafe { lance_dataset_get_data_cache_statistics(dataset, &mut statistics) }, ++ 0 ++ ); ++ assert_eq!(statistics, LanceDataCacheStatistics::default()); ++ assert_eq!( ++ unsafe { lance_dataset_get_data_cache_statistics(ptr::null(), &mut statistics) }, ++ -1 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ assert_eq!( ++ unsafe { lance_dataset_get_data_cache_statistics(dataset, ptr::null_mut()) }, ++ -1 ++ ); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ unsafe { lance_dataset_close(dataset) }; ++} ++ ++#[test] ++fn test_session_with_data_cache_rejects_invalid_options() { ++ let session = unsafe { lance_session_new_with_data_cache(0, 0, ptr::null()) }; ++ assert!(session.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ let null_directory = LanceDataCacheOptions { ++ directory: ptr::null(), ++ memory_capacity_bytes: 128 * 1024, ++ disk_capacity_bytes: 1024 * 1024, ++ read_block_size_bytes: 64 * 1024, ++ }; ++ let session = unsafe { lance_session_new_with_data_cache(0, 0, &null_directory) }; ++ assert!(session.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ let empty_directory = c_str(""); ++ let empty_directory_options = LanceDataCacheOptions { ++ directory: empty_directory.as_ptr(), ++ memory_capacity_bytes: 128 * 1024, ++ disk_capacity_bytes: 1024 * 1024, ++ read_block_size_bytes: 64 * 1024, ++ }; ++ let session = unsafe { lance_session_new_with_data_cache(0, 0, &empty_directory_options) }; ++ assert!(session.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ let cache_directory = tempfile::tempdir().unwrap(); ++ let c_cache_directory = c_str(cache_directory.path().to_str().unwrap()); ++ let unaligned_block = LanceDataCacheOptions { ++ directory: c_cache_directory.as_ptr(), ++ memory_capacity_bytes: 128 * 1024, ++ disk_capacity_bytes: 1024 * 1024, ++ read_block_size_bytes: 65_535, ++ }; ++ let session = unsafe { lance_session_new_with_data_cache(0, 0, &unaligned_block) }; ++ assert!(session.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++ ++ let zero_capacity = LanceDataCacheOptions { ++ directory: c_cache_directory.as_ptr(), ++ memory_capacity_bytes: 0, ++ disk_capacity_bytes: 0, ++ read_block_size_bytes: 64 * 1024, ++ }; ++ let session = unsafe { lance_session_new_with_data_cache(0, 0, &zero_capacity) }; ++ assert!(session.is_null()); ++ assert_eq!(lance_last_error_code(), LanceErrorCode::InvalidArgument); ++} ++ + #[test] + fn test_open_nonexistent() { + let c_uri = c_str("memory://nonexistent_dataset_xyz"); +@@ -2787,6 +2974,40 @@ fn test_dataset_restore_to_prior_version() { + unsafe { lance_dataset_close(ds) }; + } + ++#[test] ++fn test_restored_handle_has_independent_data_cache_statistics() { ++ let (_tmp, uri) = create_large_multi_fragment_dataset(10_000); ++ let c_uri = file_object_store_uri(&uri); ++ let (_cache_directory, session) = create_data_cache_session(); ++ let source = ++ unsafe { lance_dataset_open_with_session(c_uri.as_ptr(), ptr::null(), 0, session) }; ++ assert!(!source.is_null()); ++ ++ assert_eq!(scanned_row_count(source), 20_000); ++ let source_statistics = data_cache_statistics(source); ++ assert!(source_statistics.bytes_read_from_remote > 0); ++ ++ let restored = unsafe { lance_dataset_restore(source, 1) }; ++ assert!(!restored.is_null()); ++ assert_eq!(data_cache_statistics(restored), Default::default()); ++ let source_statistics_after_restore = data_cache_statistics(source); ++ ++ assert_eq!(scanned_row_count(restored), 10_000); ++ let restored_statistics = data_cache_statistics(restored); ++ assert!(restored_statistics.bytes_read_from_cache > 0); ++ ++ assert_eq!( ++ data_cache_statistics(source), ++ source_statistics_after_restore ++ ); ++ ++ unsafe { ++ lance_session_close(session); ++ lance_dataset_close(restored); ++ lance_dataset_close(source); ++ } ++} ++ + #[test] + fn test_dataset_restore_to_current_latest_writes_new_manifest() { + // Restoring to the current latest still writes a new manifest. The +diff --git a/tests/cpp/test_c_api.c b/tests/cpp/test_c_api.c +index c49ecfa..dd674eb 100644 +--- a/tests/cpp/test_c_api.c ++++ b/tests/cpp/test_c_api.c +@@ -126,6 +126,37 @@ static void test_shared_session(const char *uri) { + (unsigned long long)stats.metadata_cache_entries); + } + ++static void test_data_cache_session(const char *uri, const char *write_uri) { ++ printf(" test_data_cache_session... "); ++ ++ char cache_directory[4096]; ++ int path_len = snprintf(cache_directory, sizeof(cache_directory), ++ "%s_foyer_cache", write_uri); ++ ASSERT(path_len > 0 && (size_t)path_len < sizeof(cache_directory), ++ "cache directory path is too long"); ++ LanceDataCacheOptions options = { ++ .directory = cache_directory, ++ .memory_capacity_bytes = 128 * 1024, ++ .disk_capacity_bytes = 1024 * 1024, ++ .read_block_size_bytes = 64 * 1024, ++ }; ++ LanceSession *session = ++ lance_session_new_with_data_cache(0, 16 * 1024 * 1024, &options); ++ ASSERT(session != NULL, "data-cache session creation failed"); ++ ++ LanceDataset *ds = lance_dataset_open_with_session(uri, NULL, 0, session); ++ ASSERT(ds != NULL, "data-cache session dataset open failed"); ++ LanceDataCacheStatistics statistics; ++ memset(&statistics, 0, sizeof(statistics)); ++ ASSERT(lance_dataset_get_data_cache_statistics(ds, &statistics) == 0, ++ "data-cache dataset statistics failed"); ++ lance_session_close(session); ++ ASSERT(lance_dataset_count_rows(ds) > 0, ++ "dataset should remain valid after data-cache session close"); ++ lance_dataset_close(ds); ++ printf("OK\n"); ++} ++ + static void test_scan(const char *uri) { + printf(" test_scan... "); + +@@ -973,6 +1004,7 @@ int main(int argc, char **argv) { + + test_open_and_metadata(uri); + test_shared_session(uri); ++ test_data_cache_session(uri, write_uri); + test_scan(uri); + test_scan_with_limit(uri); + test_versions(uri); +diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp +index 17b1ab6..e1aadbd 100644 +--- a/tests/cpp/test_cpp_api.cpp ++++ b/tests/cpp/test_cpp_api.cpp +@@ -99,6 +99,28 @@ static void test_shared_session(const std::string& uri) { + PASS(); + } + ++static void test_data_cache_session(const std::string& uri, ++ const std::string& write_uri) { ++ TEST(test_data_cache_session); ++ ++ lance::DataCacheOptions options{ ++ write_uri + "_foyer_cache", ++ 128 * 1024, ++ 1024 * 1024, ++ 64 * 1024, ++ }; ++ auto session = std::make_unique( ++ 0, 16 * 1024 * 1024, options); ++ auto ds = lance::Dataset::open_with_session(*session, uri); ++ auto statistics = ds.data_cache_statistics(); ++ assert(statistics.bytes_read_from_cache == 0); ++ assert(statistics.bytes_read_from_remote == 0); ++ session.reset(); ++ assert(ds.count_rows() > 0); ++ ++ PASS(); ++} ++ + static void test_dataset_schema(const std::string& uri) { + TEST(test_dataset_schema); + +@@ -922,6 +944,7 @@ int main(int argc, char** argv) { + + test_dataset_open(uri); + test_shared_session(uri); ++ test_data_cache_session(uri, write_uri); + test_dataset_schema(uri); + test_scanner_fluent(uri); + test_scanner_async_stream_ownership(uri); diff --git a/thirdparty/patches/lance-c-0.1.8-pr-74.patch b/thirdparty/patches/lance-c-0.1.9-pr-74.patch similarity index 100% rename from thirdparty/patches/lance-c-0.1.8-pr-74.patch rename to thirdparty/patches/lance-c-0.1.9-pr-74.patch diff --git a/thirdparty/vars.sh b/thirdparty/vars.sh index af714ab9dc4ac5..9592b84e602579 100644 --- a/thirdparty/vars.sh +++ b/thirdparty/vars.sh @@ -552,10 +552,10 @@ PUGIXML_SOURCE=pugixml-1.15 PUGIXML_MD5SUM="3b894c29455eb33a40b165c6e2de5895" # lance-c -LANCE_C_DOWNLOAD="https://github.com/lance-format/lance-c/archive/refs/tags/v0.1.8.tar.gz" -LANCE_C_NAME="lance-c-v0.1.8.tar.gz" -LANCE_C_SOURCE="lance-c-0.1.8" -LANCE_C_MD5SUM="2a4af9398cdec19d5d379a27353b1266" +LANCE_C_DOWNLOAD="https://github.com/lance-format/lance-c/archive/refs/tags/v0.1.9.tar.gz" +LANCE_C_NAME="lance-c-v0.1.9.tar.gz" +LANCE_C_SOURCE="lance-c-0.1.9" +LANCE_C_MD5SUM="7138ed44e92d4bc91d5b522a6b92ed64" # all thirdparties which need to be downloaded is set in array TP_ARCHIVES export TP_ARCHIVES=( From 956531011c3e8da5c42743da713d0276296137cb Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Fri, 4 Sep 2026 14:25:35 +0800 Subject: [PATCH 7/7] [feature](lance) add cache in lance scan node --- be/src/common/config.cpp | 9 + be/src/common/config.h | 8 + .../format_v2/lance/lance_session_manager.cpp | 118 ++++++ .../format_v2/lance/lance_session_manager.h | 67 ++++ be/src/format_v2/table/lance_reader.cpp | 48 ++- be/src/format_v2/table/lance_reader.h | 5 + .../lance/lance_session_manager_test.cpp | 93 +++++ be/test/format_v2/table/lance_reader_test.cpp | 6 +- thirdparty/patches/lance-c-0.1.9-pr-73.patch | 374 +++++++++++++++--- 9 files changed, 667 insertions(+), 61 deletions(-) create mode 100644 be/src/format_v2/lance/lance_session_manager.cpp create mode 100644 be/src/format_v2/lance/lance_session_manager.h create mode 100644 be/test/format_v2/lance/lance_session_manager_test.cpp diff --git a/be/src/common/config.cpp b/be/src/common/config.cpp index 43f329d85c3d5b..728bc3366e33bf 100644 --- a/be/src/common/config.cpp +++ b/be/src/common/config.cpp @@ -1206,6 +1206,15 @@ DEFINE_Validator(variant_max_json_key_length, DEFINE_Validator(variant_storage_parse_mode, [](const int config) -> bool { return config >= 0 && config <= 2; }); +// Lance uses one BE-wide session so metadata/index caches and the optional Foyer data-file cache +// can be shared by all Lance dataset readers. +DEFINE_Int64(lance_index_cache_size_bytes, "6442450944"); // 6GB +DEFINE_Int64(lance_metadata_cache_size_bytes, "1073741824"); // 1GB +DEFINE_Bool(enable_lance_data_cache, "true"); +DEFINE_String(lance_data_cache_path, "${DORIS_HOME}/lance_data_cache"); +DEFINE_Int64(lance_data_cache_disk_capacity_bytes, "107374182400"); // 100GB +DEFINE_Int64(lance_data_cache_read_block_size_bytes, "1048576"); // 1MB + // block file cache DEFINE_Bool(enable_file_cache, "false"); // ATTENTION: For test only. Keep this enabled in production. diff --git a/be/src/common/config.h b/be/src/common/config.h index 1e50e539d96bed..f6a969111039b2 100644 --- a/be/src/common/config.h +++ b/be/src/common/config.h @@ -1249,6 +1249,14 @@ DECLARE_Bool(enable_debug_points); DECLARE_Int32(pipeline_executor_size); DECLARE_Int32(blocking_pipeline_executor_size); +// Lance shared session and optional Foyer data-file cache. +DECLARE_Int64(lance_index_cache_size_bytes); +DECLARE_Int64(lance_metadata_cache_size_bytes); +DECLARE_Bool(enable_lance_data_cache); +DECLARE_String(lance_data_cache_path); +DECLARE_Int64(lance_data_cache_disk_capacity_bytes); +DECLARE_Int64(lance_data_cache_read_block_size_bytes); + // block file cache DECLARE_Bool(enable_file_cache); DECLARE_mBool(enable_file_cache_write_from_s3_file_writer); diff --git a/be/src/format_v2/lance/lance_session_manager.cpp b/be/src/format_v2/lance/lance_session_manager.cpp new file mode 100644 index 00000000000000..c64c3c281e7ff0 --- /dev/null +++ b/be/src/format_v2/lance/lance_session_manager.cpp @@ -0,0 +1,118 @@ +// 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_session_manager.h" + +#include + +#include +#include + +#include "common/config.h" +#include "common/logging.h" +#include "format_v2/lance/lance_reader_helper.h" + +namespace doris::format::lance { +namespace { + +LanceSessionManager::Config load_lance_session_config() { + return { + .lance_index_cache_size_bytes = config::lance_index_cache_size_bytes, + .lance_metadata_cache_size_bytes = config::lance_metadata_cache_size_bytes, + .enable_lance_data_cache = config::enable_lance_data_cache, + .lance_data_cache_path = config::lance_data_cache_path, + .lance_data_cache_disk_capacity_bytes = config::lance_data_cache_disk_capacity_bytes, + .lance_data_cache_read_block_size_bytes = + config::lance_data_cache_read_block_size_bytes, + }; +} + +} // namespace + +LanceSessionManager& LanceSessionManager::instance() { + // Function-local static initialization is thread safe. Cache configuration is process scoped, + // so changing it requires a BE restart. + static LanceSessionManager manager(load_lance_session_config()); + return manager; +} + +LanceSessionManager::LanceSessionManager(Config config) : _config(std::move(config)) { + LOG(INFO) << "Creating BE-wide Lance session manager: lance_index_cache_size_bytes=" + << _config.lance_index_cache_size_bytes + << ", lance_metadata_cache_size_bytes=" + << _config.lance_metadata_cache_size_bytes + << ", enable_lance_data_cache=" << _config.enable_lance_data_cache + << ", lance_data_cache_path=" << _config.lance_data_cache_path + << ", lance_data_cache_disk_capacity_bytes=" + << _config.lance_data_cache_disk_capacity_bytes + << ", lance_data_cache_read_block_size_bytes=" + << _config.lance_data_cache_read_block_size_bytes + << ", foyer_memory_capacity_bytes=" + << _config.lance_data_cache_read_block_size_bytes; +} + +LanceSessionManager::~LanceSessionManager() { + lance_session_close(_session); +} + +Status LanceSessionManager::_initialize() { + if (_config.enable_lance_data_cache) { + const LanceDataCacheOptions data_cache_options { + .directory = _config.lance_data_cache_path.c_str(), + // Foyer's HybridCache requires a memory tier. Keep it at the minimum useful + // capacity of exactly one range-cache block; entries use WriteOnInsertion and + // are persisted to the disk tier immediately. + .memory_capacity_bytes = + static_cast(_config.lance_data_cache_read_block_size_bytes), + .disk_capacity_bytes = + static_cast(_config.lance_data_cache_disk_capacity_bytes), + .read_block_size_bytes = + static_cast(_config.lance_data_cache_read_block_size_bytes), + }; + _session = lance_session_new_with_data_cache( + static_cast(_config.lance_index_cache_size_bytes), + static_cast(_config.lance_metadata_cache_size_bytes), + &data_cache_options); + } else { + _session = lance_session_new( + static_cast(_config.lance_index_cache_size_bytes), + static_cast(_config.lance_metadata_cache_size_bytes)); + } + if (_session == nullptr) { + return lance_error("create shared Lance session"); + } + return Status::OK(); +} + +Status LanceSessionManager::open_dataset(const char* uri, const char* const* storage_options, + uint64_t version, LanceDataset** dataset) { + if (uri == nullptr || dataset == nullptr) { + return Status::InvalidArgument("Lance dataset URI and output must not be null"); + } + *dataset = nullptr; + + std::call_once(_initialize_once, [this] { _initialize_status = _initialize(); }); + RETURN_IF_ERROR(_initialize_status); + + *dataset = lance_dataset_open_with_session(uri, storage_options, version, _session); + if (*dataset == nullptr) { + return lance_error("open Lance dataset with shared session"); + } + return Status::OK(); +} + +} // namespace doris::format::lance diff --git a/be/src/format_v2/lance/lance_session_manager.h b/be/src/format_v2/lance/lance_session_manager.h new file mode 100644 index 00000000000000..905eb78b1af30d --- /dev/null +++ b/be/src/format_v2/lance/lance_session_manager.h @@ -0,0 +1,67 @@ +// 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. + +#pragma once + +#include +#include +#include + +#include "common/status.h" + +struct LanceDataset; +struct LanceSession; + +namespace doris::format::lance { + +// Owns the single Lance session shared by all queries in one BE process. The session always owns +// Lance's metadata/index caches and optionally installs the Foyer data-file cache. Readers only +// open datasets through this class and do not depend on the selected data-cache implementation. +class LanceSessionManager final { +public: + struct Config { + int64_t lance_index_cache_size_bytes = 0; + int64_t lance_metadata_cache_size_bytes = 0; + bool enable_lance_data_cache = false; + std::string lance_data_cache_path; + int64_t lance_data_cache_disk_capacity_bytes = 0; + int64_t lance_data_cache_read_block_size_bytes = 0; + }; + + static LanceSessionManager& instance(); + + // The explicit configuration constructor keeps the process-global config out of focused + // manager tests. Production readers use instance(). + explicit LanceSessionManager(Config config); + ~LanceSessionManager(); + + LanceSessionManager(const LanceSessionManager&) = delete; + LanceSessionManager& operator=(const LanceSessionManager&) = delete; + + Status open_dataset(const char* uri, const char* const* storage_options, uint64_t version, + LanceDataset** dataset); + +private: + Status _initialize(); + + Config _config; + std::once_flag _initialize_once; + LanceSession* _session = nullptr; + Status _initialize_status = Status::OK(); +}; + +} // namespace doris::format::lance diff --git a/be/src/format_v2/table/lance_reader.cpp b/be/src/format_v2/table/lance_reader.cpp index 84866ecc24c4a9..950b16bce292e8 100644 --- a/be/src/format_v2/table/lance_reader.cpp +++ b/be/src/format_v2/table/lance_reader.cpp @@ -37,6 +37,7 @@ #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 "storage/utils.h" @@ -113,6 +114,12 @@ Status LanceTableReader::init(TableReadOptions&& options) { TUnit::UNIT, LANCE_READER_PROFILE, 1); _execution_bytes_read = ADD_CHILD_COUNTER_WITH_LEVEL( _scanner_profile, "LanceExecutionIOBytesRead", TUnit::BYTES, 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); _index_partition_cache_miss_loads = ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile, "LanceIndexPartitionCacheMissLoads", TUnit::UNIT, LANCE_READER_PROFILE, 1); @@ -653,12 +660,10 @@ Status LanceTableReader::_open_dataset(const DatasetKey& key) { { SCOPED_TIMER(_dataset_open_time); - _dataset = lance_dataset_open( - key.uri.c_str(), key.storage_options.empty() ? nullptr : storage_option_ptrs.data(), - static_cast(key.version)); - } - if (_dataset == nullptr) { - return lance_error("open Lance dataset"); + RETURN_IF_ERROR(LanceSessionManager::instance().open_dataset( + key.uri.c_str(), + key.storage_options.empty() ? nullptr : storage_option_ptrs.data(), + static_cast(key.version), &_dataset)); } return Status::OK(); } @@ -1073,11 +1078,42 @@ void LanceTableReader::_close_dataset() { _fts_query_context = nullptr; } if (_dataset != nullptr) { + _collect_data_cache_statistics(); lance_dataset_close(_dataset); _dataset = nullptr; } } +void LanceTableReader::_collect_data_cache_statistics() { + if (_dataset == nullptr) { + return; + } + + LanceDataCacheStatistics statistics {}; + if (lance_dataset_get_data_cache_statistics(_dataset, &statistics) != 0) { + const auto status = lance_error("get Lance data cache statistics"); + LOG(WARNING) << "Failed to collect Lance data cache statistics: " << status.to_string(); + return; + } + + const auto set_counter = [](RuntimeProfile::Counter* counter, uint64_t value, + std::string_view metric_name) { + if (counter == nullptr) { + return; + } + if (value > static_cast(std::numeric_limits::max())) { + LOG(WARNING) << "Ignoring Lance data cache metric '" << metric_name << "' with value " + << value << " because it exceeds INT64_MAX"; + return; + } + COUNTER_SET(counter, static_cast(value)); + }; + set_counter(_data_cache_bytes_read_from_cache, statistics.bytes_read_from_cache, + "bytes_read_from_cache"); + set_counter(_data_cache_bytes_read_from_remote, statistics.bytes_read_from_remote, + "bytes_read_from_remote"); +} + Status LanceTableReader::_fill_block_from_lance_batch(LanceBatch* batch, Block* block, size_t* rows) { DORIS_CHECK(batch != nullptr); diff --git a/be/src/format_v2/table/lance_reader.h b/be/src/format_v2/table/lance_reader.h index f3c334fbd785ea..cb7a0f4e7cdac4 100644 --- a/be/src/format_v2/table/lance_reader.h +++ b/be/src/format_v2/table/lance_reader.h @@ -91,6 +91,9 @@ class LanceTableReader final : public TableReader { const TLanceFileDesc& lance_params) const; Status _configure_full_text_search(LanceScanner* scanner, const TLanceFileDesc& lance_params) const; + // Collect the cumulative statistics owned by this dataset handle. The Lance-C API returns an + // absolute snapshot, so this method replaces rather than increments the profile counters. + void _collect_data_cache_statistics(); // Keep lance-c's anonymous statistics typedef out of this header. _open_scanner installs the // strongly typed C callback adapter before forwarding the borrowed value here. static void _collect_scan_statistics(void* callback_ctx, const void* opaque_statistics); @@ -123,6 +126,8 @@ class LanceTableReader final : public TableReader { RuntimeProfile::Counter* _execution_iops = nullptr; RuntimeProfile::Counter* _execution_requests = nullptr; RuntimeProfile::Counter* _execution_bytes_read = nullptr; + RuntimeProfile::Counter* _data_cache_bytes_read_from_cache = nullptr; + RuntimeProfile::Counter* _data_cache_bytes_read_from_remote = nullptr; RuntimeProfile::Counter* _index_partition_cache_miss_loads = nullptr; RuntimeProfile::Counter* _index_comparisons = nullptr; std::unordered_map _lance_count_metrics; diff --git a/be/test/format_v2/lance/lance_session_manager_test.cpp b/be/test/format_v2/lance/lance_session_manager_test.cpp new file mode 100644 index 00000000000000..3421c5e9ad02cf --- /dev/null +++ b/be/test/format_v2/lance/lance_session_manager_test.cpp @@ -0,0 +1,93 @@ +// 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_session_manager.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace doris::format::lance { +namespace { + +using LanceDatasetPtr = std::unique_ptr; + +std::filesystem::path lance_fixture_path() { + return std::filesystem::path(__FILE__).parent_path().parent_path() / + "table/lance/data/all_types.lance"; +} + +std::filesystem::path unique_cache_path() { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + ("doris_lance_foyer_cache_" + std::to_string(suffix)); +} + +TEST(LanceSessionManagerTest, SessionAndDataCacheConfigurationsAreIndependent) { + LanceSessionManager::Config config { + .lance_index_cache_size_bytes = 0, + .lance_metadata_cache_size_bytes = 0, + .enable_lance_data_cache = false, + // These are deliberately invalid and must be ignored while the data cache is off. + .lance_data_cache_path = "", + .lance_data_cache_disk_capacity_bytes = -1, + .lance_data_cache_read_block_size_bytes = -1, + }; + LanceSessionManager manager(std::move(config)); + LanceDataset* raw_dataset = nullptr; + ASSERT_TRUE(manager + .open_dataset(lance_fixture_path().c_str(), nullptr, 0, &raw_dataset) + .ok()); + LanceDatasetPtr dataset(raw_dataset, lance_dataset_close); + ASSERT_NE(dataset, nullptr); +} + +TEST(LanceSessionManagerTest, CreatesFoyerBackedSession) { + const auto cache_path = unique_cache_path(); + std::filesystem::create_directories(cache_path); + const auto cleanup = [&cache_path] { + std::error_code error; + std::filesystem::remove_all(cache_path, error); + }; + + LanceSessionManager::Config config { + .lance_index_cache_size_bytes = 0, + .lance_metadata_cache_size_bytes = 0, + .enable_lance_data_cache = true, + .lance_data_cache_path = cache_path.string(), + .lance_data_cache_disk_capacity_bytes = 32 * 1024 * 1024, + .lance_data_cache_read_block_size_bytes = 64 * 1024, + }; + { + LanceSessionManager manager(std::move(config)); + LanceDataset* raw_dataset = nullptr; + const auto status = manager.open_dataset(lance_fixture_path().c_str(), nullptr, 0, + &raw_dataset); + LanceDatasetPtr dataset(raw_dataset, lance_dataset_close); + EXPECT_TRUE(status.ok()) << status; + EXPECT_NE(dataset, nullptr); + } + cleanup(); +} + +} // namespace +} // namespace doris::format::lance diff --git a/be/test/format_v2/table/lance_reader_test.cpp b/be/test/format_v2/table/lance_reader_test.cpp index 3fe4d01a65fbda..0eceb92128da0b 100644 --- a/be/test/format_v2/table/lance_reader_test.cpp +++ b/be/test/format_v2/table/lance_reader_test.cpp @@ -833,6 +833,8 @@ TEST(LanceTableReaderVectorSearchTest, SearchesMultipleFragmentSplits) { "LanceExecutionIOOps", "LanceExecutionIORequests", "LanceExecutionIOBytesRead", + "LanceDataCacheBytesReadFromCache", + "LanceDataCacheBytesReadFromRemote", "LanceIndexPartitionCacheMissLoads", "LanceIndexComparisons", "LanceFragmentsScanned", @@ -955,7 +957,9 @@ TEST(LanceTableReaderVectorSearchTest, ReturnsStableGlobalRowIdsAndFetchesPayloa EXPECT_NE(fetch_profile.get_counter("LanceRowIdFetchTotalTime"), nullptr); expect_lance_profile_hierarchy(&fetch_profile, {"LanceDatasetOpenTime", "LanceRowIdTakeReadTime", - "LanceArrowToDorisBlockTime", "LanceRowIdFetchTotalTime"}); + "LanceArrowToDorisBlockTime", "LanceRowIdFetchTotalTime", + "LanceDataCacheBytesReadFromCache", + "LanceDataCacheBytesReadFromRemote"}); EXPECT_TRUE(payload_reader.close().ok()); } diff --git a/thirdparty/patches/lance-c-0.1.9-pr-73.patch b/thirdparty/patches/lance-c-0.1.9-pr-73.patch index 453ef9f9c6c74c..7d5eb45ad6ce7d 100644 --- a/thirdparty/patches/lance-c-0.1.9-pr-73.patch +++ b/thirdparty/patches/lance-c-0.1.9-pr-73.patch @@ -1,25 +1,25 @@ -From a39bc8b08cd1367b7445cb9acf3309faa1f3d610 Mon Sep 17 00:00:00 2001 +From a4c71309ddb76ad79808e3e8f7797bcd9bfc174a Mon Sep 17 00:00:00 2001 From: zhangstar333 Date: Thu, 3 Sep 2026 13:00:43 +0800 Subject: [PATCH] foyer --- - Cargo.lock | 205 ++++++++ - Cargo.toml | 4 + - README.md | 22 + - include/lance/lance.h | 62 +++ - include/lance/lance.hpp | 29 ++ - src/data_cache.rs | 68 +++ - src/dataset.rs | 11 + - src/foyer_data_cache.rs | 950 +++++++++++++++++++++++++++++++++++++ - src/lib.rs | 4 + - src/restore.rs | 8 + - src/session.rs | 13 +- - src/writer.rs | 1 + - tests/c_api_test.rs | 221 +++++++++ - tests/cpp/test_c_api.c | 32 ++ - tests/cpp/test_cpp_api.cpp | 23 + - 15 files changed, 1652 insertions(+), 1 deletion(-) + Cargo.lock | 205 ++++++ + Cargo.toml | 4 + + README.md | 22 + + include/lance/lance.h | 63 ++ + include/lance/lance.hpp | 29 + + src/data_cache.rs | 68 ++ + src/dataset.rs | 11 + + src/foyer_data_cache.rs | 1215 ++++++++++++++++++++++++++++++++++++ + src/lib.rs | 4 + + src/restore.rs | 8 + + src/session.rs | 13 +- + src/writer.rs | 1 + + tests/c_api_test.rs | 221 +++++++ + tests/cpp/test_c_api.c | 32 + + tests/cpp/test_cpp_api.cpp | 23 + + 15 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 src/data_cache.rs create mode 100644 src/foyer_data_cache.rs @@ -343,14 +343,14 @@ index d072a5d..342928c 100644 prost = "0.14" snafu = "0.9" diff --git a/README.md b/README.md -index 2056671..99502d9 100644 +index 2056671..d9a5f2b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Based on the [liblance RFC](https://github.com/lance-format/lance/discussions/60 | [x] | Async scan | Callback-based `lance_scanner_scan_async()` for non-blocking scans | | [x] | Dataset metadata | `lance_dataset_version()`, `lance_dataset_count_rows()`, `lance_dataset_latest_version()` | | [x] | Filter pushdown | `lance_scanner_set_substrait_filter()` accepts a serialized Substrait `ExtendedExpression`; `lance_scanner_additional_sql_filter()` adds SQL predicates with AND before scanning starts | -+| [x] | Data-file cache | Optional Foyer memory/disk cache for immutable `data/*.lance` batched range reads | ++| [x] | Data-file cache | Optional Foyer memory/disk cache for immutable `data/*.lance` reads | ## Building @@ -360,10 +360,10 @@ index 2056671..99502d9 100644 +To add a process-local memory/disk cache for remote Lance data-file reads, +create the session with Foyer configuration. The cache is deliberately narrow: -+only batched range reads of direct `data/*.lance` children are cached. Single -+range reads, manifests, deletion files, and index files keep using Lance's -+normal paths. Use one shared session for datasets that share the cache -+directory. ++whole-object, single-range, and batched range reads of direct `data/*.lance` ++children are cached. Conditional and versioned reads, plus manifests, deletion ++files, and index files, keep using Lance's normal paths. Use one shared session ++for datasets that share the cache directory. + +```cpp +lance::DataCacheOptions data_cache{ @@ -383,19 +383,20 @@ index 2056671..99502d9 100644 `lance_dataset_open` takes a `version` argument — `0` means the latest, any diff --git a/include/lance/lance.h b/include/lance/lance.h -index 3bf291f..2cd5be0 100644 +index 3bf291f..e4c593e 100644 --- a/include/lance/lance.h +++ b/include/lance/lance.h -@@ -206,6 +206,35 @@ typedef struct LanceSessionCacheStats { +@@ -206,6 +206,36 @@ typedef struct LanceSessionCacheStats { uint64_t metadata_cache_size_bytes; } LanceSessionCacheStats; +/** + * Configuration for the optional Foyer cache of immutable Lance data files. + * -+ * Only batched range reads of direct `data/.lance` children are cached. -+ * Single-range reads, metadata, deletion files, and index files continue to -+ * use Lance's normal paths. ++ * Whole-object, single-range, and batched range reads of direct ++ * `data/.lance` children are cached. Conditional and versioned reads, ++ * plus metadata, deletion files, and index files, continue to use Lance's normal ++ * paths. + */ +typedef struct LanceDataCacheOptions { + const char* directory; @@ -422,7 +423,7 @@ index 3bf291f..2cd5be0 100644 /** * Create a session that can share metadata and index caches across datasets. * -@@ -217,6 +246,25 @@ LanceSession* lance_session_new( +@@ -217,6 +247,25 @@ LanceSession* lance_session_new( uint64_t metadata_cache_size_bytes ); @@ -448,7 +449,7 @@ index 3bf291f..2cd5be0 100644 /** * Close a session handle. Safe to call with NULL. Datasets previously opened * with the session remain valid and retain the shared cache state. -@@ -273,6 +321,20 @@ LanceDataset* lance_dataset_open_with_session( +@@ -273,6 +322,20 @@ LanceDataset* lance_dataset_open_with_session( const LanceSession* session ); @@ -644,14 +645,14 @@ index cc1f87c..76fd39e 100644 } diff --git a/src/foyer_data_cache.rs b/src/foyer_data_cache.rs new file mode 100644 -index 0000000..32cc241 +index 0000000..9a10e43 --- /dev/null +++ b/src/foyer_data_cache.rs -@@ -0,0 +1,950 @@ +@@ -0,0 +1,1215 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + -+//! Foyer-backed cache for immutable Lance data-file batched range reads. ++//! Foyer-backed cache for immutable Lance data-file reads. + +use std::collections::{BTreeMap, HashMap}; +use std::ffi::c_char; @@ -671,8 +672,9 @@ index 0000000..32cc241 +use lance_io::object_store::WrappingObjectStore; +use object_store::path::Path; +use object_store::{ -+ CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, -+ ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions, Result, ++ CopyOptions, GetOptions, GetResult, GetResultPayload, ListResult, MultipartUpload, ObjectMeta, ++ ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload, PutResult, ++ RenameOptions, Result, +}; + +use crate::data_cache::{DataCacheFactory, DatasetDataCache, LanceDataCacheStatistics}; @@ -699,7 +701,7 @@ index 0000000..32cc241 +} + +/// Create a shared Lance session with a Foyer cache for immutable data-file -+/// batched range reads. ++/// whole-object, single-range, and batched range reads. +/// +/// `data_cache_options` and its `directory` field must not be NULL. The +/// pointed-to values are copied before this function returns. @@ -1001,12 +1003,15 @@ index 0000000..32cc241 + // that exact wrapper back to its origin before attaching fresh + // dataset-scoped counters. + let original = self.cache.unwrap_store(original); ++ let reader = DataCacheReader { ++ cache: self.cache.clone(), ++ store_prefix: store_prefix.to_owned(), ++ original: original.clone(), ++ statistics: self.statistics.clone(), ++ }; + let cached_store = + Arc::new_cyclic(|weak: &Weak| DataCacheObjectStore { -+ cache: self.cache.clone(), -+ store_prefix: store_prefix.to_owned(), -+ original: original.clone(), -+ statistics: self.statistics.clone(), ++ reader, + identity: weak.as_ptr() as usize, + }); + let wrapped: Arc = cached_store.clone(); @@ -1017,26 +1022,117 @@ index 0000000..32cc241 + +#[derive(Debug)] +struct DataCacheObjectStore { ++ reader: DataCacheReader, ++ identity: usize, ++} ++ ++#[derive(Clone, Debug)] ++struct DataCacheReader { + cache: FoyerDataCache, + store_prefix: String, + original: Arc, + statistics: Arc, -+ identity: usize, +} + +impl Drop for DataCacheObjectStore { + fn drop(&mut self) { -+ self.cache.forget_wrapper(self.identity); ++ self.reader.cache.forget_wrapper(self.identity); + } +} + +impl Display for DataCacheObjectStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { -+ write!(f, "FoyerDataCache({})", self.original) ++ write!(f, "FoyerDataCache({})", self.reader.original) + } +} + +impl DataCacheObjectStore { ++ fn is_cache_safe_get(options: &GetOptions) -> bool { ++ !options.head ++ && options.if_match.is_none() ++ && options.if_none_match.is_none() ++ && options.if_modified_since.is_none() ++ && options.if_unmodified_since.is_none() ++ && options.version.is_none() ++ && options.extensions.is_empty() ++ } ++ ++ async fn cached_get(&self, location: &Path, options: GetOptions) -> Result { ++ // Fetch metadata separately so the returned GetResult retains the origin's identity while ++ // its payload uses the same block cache as get_ranges(). This also provides the object size ++ // needed to resolve bounded, offset, and suffix ranges. ++ let GetResult { ++ meta: metadata, ++ attributes, ++ .. ++ } = self ++ .reader ++ .original ++ .get_opts( ++ location, ++ GetOptions { ++ head: true, ++ ..Default::default() ++ }, ++ ) ++ .await?; ++ let object_size = metadata.size; ++ self.reader.cache.cache.insert( ++ self.reader ++ .cache ++ .size_key(&self.reader.store_prefix, location), ++ Bytes::copy_from_slice(&object_size.to_le_bytes()), ++ ); ++ ++ let range = match options.range.clone() { ++ Some(requested) => match requested.as_range(object_size) { ++ Ok(range) if !range.is_empty() => range, ++ // Preserve the origin's exact error for invalid or empty ranges. ++ _ => return self.reader.original.get_opts(location, options).await, ++ }, ++ None => 0..object_size, ++ }; ++ ++ let reader = self.reader.clone(); ++ let stream_location = location.clone(); ++ let stream_range = range.clone(); ++ let stream = futures::stream::try_unfold( ++ (reader, stream_location, stream_range), ++ |(reader, location, remaining)| async move { ++ if remaining.is_empty() { ++ return Ok(None); ++ } ++ ++ // Yield no more than the remainder of one cache block. The next block is not ++ // requested until the consumer polls again, so cancellation drops the pending ++ // range without downloading or retaining the rest of the object. ++ let block_size = reader.cache.read_block_size as u64; ++ let bytes_to_boundary = block_size - remaining.start % block_size; ++ let end = remaining ++ .start ++ .saturating_add(bytes_to_boundary) ++ .min(remaining.end); ++ let chunk_range = remaining.start..end; ++ let chunk = reader ++ .cached_ranges(&location, std::slice::from_ref(&chunk_range)) ++ .await? ++ .into_iter() ++ .next() ++ .ok_or_else(|| cache_error(format!("missing get result for {location}")))?; ++ Ok(Some((chunk, (reader, location, end..remaining.end)))) ++ }, ++ ); ++ let payload = GetResultPayload::Stream(Box::pin(stream)); ++ Ok(GetResult { ++ payload, ++ meta: metadata, ++ range, ++ attributes, ++ }) ++ } ++} ++ ++impl DataCacheReader { + async fn read_origin_ranges( + &self, + location: &Path, @@ -1286,7 +1382,7 @@ index 0000000..32cc241 + payload: PutPayload, + opts: PutOptions, + ) -> Result { -+ self.original.put_opts(location, payload, opts).await ++ self.reader.original.put_opts(location, payload, opts).await + } + + async fn put_multipart_opts( @@ -1294,18 +1390,25 @@ index 0000000..32cc241 + location: &Path, + opts: PutMultipartOptions, + ) -> Result> { -+ self.original.put_multipart_opts(location, opts).await ++ self.reader ++ .original ++ .put_multipart_opts(location, opts) ++ .await + } + + async fn get_opts(&self, location: &Path, options: GetOptions) -> Result { -+ self.original.get_opts(location, options).await ++ if FoyerDataCache::is_cacheable_data_file(location) && Self::is_cache_safe_get(&options) { ++ self.cached_get(location, options).await ++ } else { ++ self.reader.original.get_opts(location, options).await ++ } + } + + async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> Result> { + if FoyerDataCache::is_cacheable_data_file(location) { -+ self.cached_ranges(location, ranges).await ++ self.reader.cached_ranges(location, ranges).await + } else { -+ self.original.get_ranges(location, ranges).await ++ self.reader.original.get_ranges(location, ranges).await + } + } + @@ -1313,11 +1416,11 @@ index 0000000..32cc241 + &self, + locations: BoxStream<'static, Result>, + ) -> BoxStream<'static, Result> { -+ self.original.delete_stream(locations) ++ self.reader.original.delete_stream(locations) + } + + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result> { -+ self.original.list(prefix) ++ self.reader.original.list(prefix) + } + + fn list_with_offset( @@ -1325,19 +1428,19 @@ index 0000000..32cc241 + prefix: Option<&Path>, + offset: &Path, + ) -> BoxStream<'static, Result> { -+ self.original.list_with_offset(prefix, offset) ++ self.reader.original.list_with_offset(prefix, offset) + } + + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result { -+ self.original.list_with_delimiter(prefix).await ++ self.reader.original.list_with_delimiter(prefix).await + } + + async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> { -+ self.original.copy_opts(from, to, options).await ++ self.reader.original.copy_opts(from, to, options).await + } + + async fn rename_opts(&self, from: &Path, to: &Path, options: RenameOptions) -> Result<()> { -+ self.original.rename_opts(from, to, options).await ++ self.reader.original.rename_opts(from, to, options).await + } +} + @@ -1346,7 +1449,9 @@ index 0000000..32cc241 + use std::sync::mpsc; + use std::time::Duration; + ++ use futures::StreamExt; + use lance_io::object_store::ChainedWrappingObjectStore; ++ use object_store::GetRange; + use object_store::memory::InMemory; + + use super::*; @@ -1433,6 +1538,167 @@ index 0000000..32cc241 + } + + #[tokio::test] ++ async fn caches_small_data_file_whole_object_reads() { ++ let directory = tempfile::tempdir().unwrap(); ++ let cache = FoyerDataCache::try_new(directory.path(), 128 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data_path = Path::from("table.lance/data/small.lance"); ++ let data = Bytes::from(vec![7; 42_000]); ++ original.put(&data_path, data.clone().into()).await.unwrap(); ++ ++ let (wrapped, statistics) = wrap_for_test(&cache, original); ++ let first = wrapped ++ .get(&data_path) ++ .await ++ .unwrap() ++ .bytes() ++ .await ++ .unwrap(); ++ assert_eq!(first, data); ++ assert_eq!( ++ statistics.snapshot(), ++ LanceDataCacheStatistics { ++ bytes_read_from_cache: 0, ++ bytes_read_from_remote: 42_000, ++ } ++ ); ++ ++ let second = wrapped ++ .get(&data_path) ++ .await ++ .unwrap() ++ .bytes() ++ .await ++ .unwrap(); ++ assert_eq!(second, data); ++ assert_eq!( ++ statistics.snapshot(), ++ LanceDataCacheStatistics { ++ bytes_read_from_cache: 42_000, ++ bytes_read_from_remote: 42_000, ++ } ++ ); ++ } ++ ++ #[tokio::test] ++ async fn streams_large_data_file_gets_with_bounded_read_ahead() { ++ let directory = tempfile::tempdir().unwrap(); ++ let block_size = 64 * 1024; ++ let cache = FoyerDataCache::try_new(directory.path(), 512 * 1024, 1024 * 1024, block_size) ++ .await ++ .unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data_path = Path::from("table.lance/data/large.lance"); ++ let data = Bytes::from(vec![7; 4 * block_size]); ++ original.put(&data_path, data.clone().into()).await.unwrap(); ++ ++ let (wrapped, statistics) = wrap_for_test(&cache, original); ++ let result = wrapped.get(&data_path).await.unwrap(); ++ assert_eq!(statistics.snapshot(), LanceDataCacheStatistics::default()); ++ ++ let mut stream = result.into_stream(); ++ let first = stream.next().await.unwrap().unwrap(); ++ assert_eq!(first, data.slice(..block_size)); ++ assert_eq!( ++ statistics.snapshot(), ++ LanceDataCacheStatistics { ++ bytes_read_from_cache: 0, ++ bytes_read_from_remote: block_size as u64, ++ } ++ ); ++ ++ drop(stream); ++ assert_eq!( ++ statistics.snapshot().bytes_read_from_remote, ++ block_size as u64 ++ ); ++ } ++ ++ #[tokio::test] ++ async fn caches_single_data_file_range_reads() { ++ let directory = tempfile::tempdir().unwrap(); ++ let cache = FoyerDataCache::try_new(directory.path(), 512 * 1024, 1024 * 1024, 64 * 1024) ++ .await ++ .unwrap(); ++ let original = Arc::new(InMemory::new()); ++ let data = Bytes::from((0..100_000).map(|value| value as u8).collect::>()); ++ let cases = [ ++ ( ++ Path::from("table.lance/data/bounded.lance"), ++ GetRange::Bounded(1_000..2_000), ++ 1_000..2_000, ++ ), ++ ( ++ Path::from("table.lance/data/offset.lance"), ++ GetRange::Offset(90_000), ++ 90_000..100_000, ++ ), ++ ( ++ Path::from("table.lance/data/suffix.lance"), ++ GetRange::Suffix(500), ++ 99_500..100_000, ++ ), ++ ]; ++ for (path, _, _) in &cases { ++ original.put(path, data.clone().into()).await.unwrap(); ++ } ++ ++ let (wrapped, statistics) = wrap_for_test(&cache, original); ++ for (path, requested, expected_range) in cases { ++ let expected = data.slice(expected_range.start as usize..expected_range.end as usize); ++ let before = statistics.snapshot(); ++ let first = wrapped ++ .get_opts(&path, GetOptions::new().with_range(Some(requested.clone()))) ++ .await ++ .unwrap() ++ .bytes() ++ .await ++ .unwrap(); ++ assert_eq!(first, expected); ++ assert_eq!( ++ statistics.snapshot().bytes_read_from_remote, ++ before.bytes_read_from_remote + expected.len() as u64 ++ ); ++ ++ let second = wrapped ++ .get_opts(&path, GetOptions::new().with_range(Some(requested))) ++ .await ++ .unwrap() ++ .bytes() ++ .await ++ .unwrap(); ++ assert_eq!(second, expected); ++ assert_eq!( ++ statistics.snapshot().bytes_read_from_cache, ++ before.bytes_read_from_cache + expected.len() as u64 ++ ); ++ } ++ ++ let before = statistics.snapshot(); ++ let conditional = GetOptions::new().with_if_match(Some("wrong-etag")); ++ assert!( ++ wrapped ++ .get_opts(&Path::from("table.lance/data/bounded.lance"), conditional) ++ .await ++ .is_err() ++ ); ++ assert_eq!(statistics.snapshot(), before); ++ ++ assert!( ++ wrapped ++ .get_range( ++ &Path::from("table.lance/data/bounded.lance"), ++ 100_000..100_001 ++ ) ++ .await ++ .is_err() ++ ); ++ assert_eq!(statistics.snapshot(), before); ++ } ++ ++ #[tokio::test] + async fn recovers_cached_data_from_disk() { + let directory = tempfile::tempdir().unwrap(); + let original = Arc::new(InMemory::new());