Skip to content

[feature](be) Read Lance datasets with FileScannerV2 - #69581

Merged
yiguolei merged 17 commits into
apache:masterfrom
morningman:b2m-t37-lance-be-reader
Nov 6, 2026
Merged

yiguolei merged 17 commits into
apache:masterfrom
morningman:b2m-t37-lance-be-reader

Conversation

@morningman

@morningman morningman commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: None

Related PR: #65730 #66608 #66805 #66841 #66999 #67005 #67011 #67110 #67268 #67289 #67325 #67650 #67921 #68028 #68335 #68375 #68615 #68690 #68813 #68815 #68707 #68649

Problem Summary:

In short. This ports the Lance BE read path of branch-4.1 / branch-4.2 to master, in its branch-4.2 form as of cabcf45c1af, plus the BE side of three 4.x changes merged after that snapshot: the search filter prefilter flag and profile layout (#68813), distance bounds for vector_search (#68815), and the split-level index rule of #68707 that #68815 relies on. It gathers the BE parts of the PRs listed above. Master links liblance_c but has no Lance reader, so every FORMAT_LANCE scan fails today. With this PR the BE reads Lance datasets through FileScannerV2: plain scans, vector search and full-text search, predicate and runtime-filter pushdown, the schema fetch of a local Lance TVF, and the second phase of TopN lazy materialization. The PR is BE only. The FE connector plugin and the regression suites come in separate PRs, so until then nothing on master plans a Lance scan, and the BE unit tests carry the proof.

Original authors: @zhangstar333 @Gabriel39 @wangzhaobo957-cloud @Jay-ju @FANNG1

Background. Lance shipped in 4.1.4: the Lance catalog, local() / s3() with format=lance, and the vector_search / full_text_search TVFs. On master the FE side is being rebuilt as a connector plugin (fe-connector-lance), and the BE needs the reader from 4.x. The thrift ids are the branch-4.1 / 4.2 ones, including TExternalSearchRequest.5 (prefilter) and TVectorSearchParams.6/7 (distance bounds) added after the snapshot, so a BE built from master also reads the Lance scan ranges and search requests that a 4.x FE sends during a rolling upgrade. The one exception is TVectorSearchOptions 6, branch-4.2's approx_mode (#69852, merged into branch-4.2 after the snapshot and not ported): it is reserved in a comment, and a master BE ignores it, so during a rolling upgrade a 4.2 FE that asks for fast or accurate gets Lance's default (normal).

The problem, and what it cost.

master today with this PR
A FORMAT_LANCE scan FileScannerV2 has no Lance reader, so the scan fails with "FileScannerV2 does not support file format" read by LanceTableReader in FileScannerV2
fetch_table_schema for a local Lance TVF not handled returns the Arrow schema mapped to Doris types; Null leaves keep NULL_TYPE
The row location of a Lance row in TopN lazy materialization GlobalRowLoacationV2 only has a 32-bit row id, but a Lance row id is a 64-bit id within the snapshot a version 1 layout carries the 64-bit row id; phase two reads the rows with the dataset's take_rows

How this PR fixes it.

  • Reader: be/src/format_v2/lance/* and be/src/format_v2/table/lance_reader.*, as on branch-4.2 except one local renamed for master's -Wshadow. The unit tests construct RuntimeState(TQueryOptions(), globals), because master removed the globals-only constructor.

  • FileScannerV2: the reader dispatch and _to_file_format handle FORMAT_LANCE / "lance"; FileFormat::LANCE is added to its two exhaustive switches. Since [refactor](scan) Remove legacy file scanner and deprecate rollout option #69723 every file scan uses FileScannerV2, so nothing else routes Lance scans (on branch-4.2 a Lance query fails with NotSupported when enable_file_scanner_v2 is off).

  • Row locations: ROW_VERSION {FILE_LOCAL_ROW_ID = 0, LANCE_DATASET_ROW_ID = 1} and a union layout for GlobalRowLoacationV2. Version 0 keeps its byte layout, which static asserts pin. PRequestBlockDesc.row_id becomes uint64 (a proto2 varint, so the wire format is compatible). The materialization operator decodes row locations by version, rejects an unknown version or a wrong size, and stamps row_location_version. RowIdStorageReader validates the counts and the version, range-checks internal row ids against uint32, and groups Lance rows by plan node, snapshot version and dataset URI, so each dataset is read with one take_rows. Master's segment batching, scan-slot dedup and fetch concurrency stay as they are.

  • Thrift (PlanNodes.thrift): the external search request body (TVectorElementType, TVectorMetric, TSearchVector, TVectorSearchParams 1-7, TFtsCoverageMode, TFtsQueryType, TFtsMatchOperator, TFullTextSearchParams, TSearchFilterFormat, TSearchFilter, TVectorSearchOptions, TExternalSearchQuery), TExternalSearchRequest 2-5 and TLanceFileDesc 4-6, all with the branch-4.1 / 4.2 ids. The enums spell out their 4.x values. No id is reused. On master, [chore](paimon) Upgrade paimon-rust and reserve ANN Thrift fields #70075 reserved TExternalSearchRequest 2-5 in comments and added 6 (paimon_options); this PR replaces those comments with the fields and leaves 6 as it is. TVectorSearchOptions 6 is reserved in a comment for branch-4.2's approx_mode ([feature](lance) Support approx_mode in vector_search #69852).

  • After the snapshot (one commit each):

  • Review fixes:

    • A nested array goes to the Doris SerDes without a copy only when it and every descendant start at offset 0 and reference exactly their child ranges. Lance's legacy (v1) decoder can return a struct or list that is tight at its own level over a Boolean child with a bit offset (a batch that starts mid-byte in the bitmap), and the SerDes reject that under Arrow input validation.
    • A zero-field STRUCT<>, which Lance decodes, is reported as an unsupported column at any nesting level, like the other unsupported Lance types. It used to be typed as a STRUCT that ColumnStruct cannot hold (its row count comes from its first child).
    • A Lance Decimal256 field with precision <= 38 is narrowed to Decimal128 by a safe Arrow cast before decoding. Doris types it DECIMAL32/64/128I by precision, and those SerDes read only Decimal128 arrays; the BE used to crash on the first value, also for nested leaves. Only values a reader can see are cast: before a nested leaf is narrowed, every child slot that no visible row references (under a NULL list, fixed-size-list, map or struct row, or outside a sliced parent) is marked NULL, so a hidden value does not fail the cast and a visible value outside the declared precision still does; map keys, which cannot be NULL, stay checked.
    • FLOAT / DOUBLE >= runtime filters stay residual-only. Lance compares floats by totalOrder, so a pushed x >= c dropped negative-NaN rows that Doris keeps.
    • Runtime filters that mention INT64_MIN stay residual-only. The pinned Lance SQL parser reads -9223372036854775808 as the negation of a magnitude that it parses as i64 first; the overflow turns the literal into a Float64, which the planner cannot coerce to Int64 or Decimal, so a pushed IN, >= or <= filter on an INT64 or scale-zero decimal column failed planning and the query failed. Every other i64 is still pushed.
    • The shared Arrow DATE32 decoding (DataTypeDateV2SerDe::read_column_from_arrow) rejects days outside 0000-01-01..9999-12-31 instead of returning a non-null 0000-00-00, skips null slots and adds the epoch in 64 bits: the DATE32 range part of branch-4.1 / 4.2 [fix](arrow) Correct year-zero DATE conversion on branch-4.1 #68649. It also changes ADBC, Arrow stream load and paimon-rust reads.
    • Child values hidden by a NULL list, fixed-size-list, map or struct row are not decoded: the array, map and struct SerDes give the element SerDe a validity bitmap that also clears those slots (mask_arrow_children_hidden_by_null_parents()). Arrow leaves them unspecified, and Lance's v2.1 miniblock encoding keeps them, so a hidden 10000-01-01 under a NULL fixed-size list used to fail the DATE32 range check above. Visible values are unchanged; this applies to ADBC, Arrow stream load and paimon-rust as well. The masks come from ExecEnv's tracked Arrow pool. A nested Lance array that is compacted before decoding is masked the same way, at every level, before its ValidateFull(), so a hidden invalid UTF-8 string, out-of-precision decimal, Date64 or time no longer fails the batch while a visible one still does. Since [fix](be) Reject Arrow timestamps outside the DATETIMEV2 and TIMESTAMPTZ ranges #69697 the same masks keep hidden DATETIMEV2 / TIMESTAMPTZ values out of its range checks. A mask costs at most one pass per batch: a NULL row that owns no child slots, or only NULL ones, allocates nothing, and Arrow stream load masks each retained IPC batch once (mask_arrow_descendants_not_visible()) before it hands row ranges to the SerDes, so its per-block reads allocate nothing. The one per-block mask left is for map keys under a NULL map row that still owns entries, since a MapArray cannot hold NULL keys; Arrow's map builder writes NULL map rows without entries.
  • Core: to_protobuf keeps NULL_TYPE for a NULL literal type, so a Null leaf in ARRAY / MAP / STRUCT no longer comes back as BOOLEAN. The nullable serde reads an Arrow Null array as NULLs without touching buffers that the array does not have.

  • Configs, caches and metrics, with the 4.x names and defaults:

    Config Default
    lance_index_cache_size_bytes 10 GiB
    lance_metadata_cache_size_bytes 1 GiB
    enable_lance_data_cache true
    lance_data_cache_path ${DORIS_HOME}/lance_data_cache
    lance_data_cache_disk_capacity_bytes 100 GiB
    lance_data_cache_read_block_size_bytes 1 MiB
    lance_io_buffer_size_bytes (mutable) 2 GiB
    lance_batch_readahead (mutable) 5
    lance_fragment_readahead (mutable) 5

    The BE-wide Lance session (index cache, metadata cache and the optional Foyer disk cache) is created by the first Lance scan, so a BE that never reads Lance creates no cache directory. The first six configs take effect after a restart. A negative cache size (and, while the data cache is enabled, a negative disk capacity or read block size) fails the first Lance scan with a message that names the setting, as does any data-cache value lance-c rejects; the rest of the BE keeps serving. Ten lance_session_* metrics report the two in-memory caches. With the defaults, the disk tier keeps 51,200 sparse 2 MiB block files open from the first Lance scan on (100 GiB in blocks of twice the read block size), the same as on branch-4.1 / 4.2. Size lance_data_cache_disk_capacity_bytes and lance_data_cache_read_block_size_bytes against the BE's open-file limit, or set enable_lance_data_cache = false.

    The index and metadata caches live in lance-c memory: they are not charged to a MemTracker or registered with CacheManager, so memory GC, the cache capacity adjustment under memory pressure and /api/clear_cache/all do not shrink them, and enable_lance_data_cache = false does not turn them off. lance-c has no call to clear, evict or resize them, so size them with the two configs. The disk tier keys blocks by Lance's store prefix (scheme$bucket for S3, GCS and OSS, without the endpoint) and the object path (thirdparty/patches/lance-c-foyer.patch). Several endpoints that serve the same bucket name and data-file path with different bytes (data files registered under chosen names or replaced in place; Lance's writers use random file names) share entries, also across a BE restart; such a deployment should set enable_lance_data_cache = false. Both are as on branch-4.1 / 4.2 and tracked as follow-ups.

    A Lance scan's native working set, that is the I/O buffered up to lance_io_buffer_size_bytes per scanner, the decode readahead and the operators of a search plan such as the top-k of a flat vector search, is allocated inside lance-c and is charged to neither the query nor its workload group. It counts toward the BE process memory only, so memory GC, which ranks queries by tracked memory, does not attribute it to the Lance query. lance-c accepts no memory budget, so on BEs with little memory headroom size lance_io_buffer_size_bytes, lance_batch_readahead and lance_fragment_readahead for the number of concurrent Lance scanners. As on branch-4.1 / 4.2; tracked as a follow-up.

  • Build: macOS links -framework IOKit. This is the first time objects of liblance_c.a are pulled into doris_be, so the binary and the link time grow. With this PR util/rle_encoding.h reaches 231 TUs and util/threadpool.h 589, the same as master 315c2e00331 alone (limits 275 and 660 since [improvement](be) Reduce transitive dependencies in shared headers #69717), and gen_cpp/internal_service.pb.h 562 (master alone 559, limit 660); the reach budgets are unchanged.

Not included: the Lance index metadata scanner (LANCE_INDEX_ENTRIES); the FE side, including the rolling-upgrade guard that keeps Lance lazy materialization off while an old BE could decode a version 1 row location as version 0; the regression suites; the rest of #68707 (searching historical versions, tags and branches, rejecting fragment ids missing from the snapshot, naming the snapshot in errors and the profile). The InitReaderAvgTime / GetBlockAvgTime profile fix that #67005 carried goes in a separate PR.

Results.

master with this PR
A FORMAT_LANCE query fails before reading anything read by LanceTableReader in FileScannerV2
A Lance row in the second phase of TopN lazy materialization cannot be represented a version 1 row location, read back with take_rows
A row location of an unknown version or size in phase two decoded as version 0 rejected with an error
An Arrow DATE32 day outside 0000-01-01..9999-12-31 (ADBC, Arrow stream load, paimon-rust, Lance) read as a non-null 0000-00-00 the read fails with InvalidArgument

BE unit tests (ASAN, macOS arm64, against master's pinned lance-c), on the head rebased onto master 378404eeb12: 1029 cases in 54 suites run, 1027 pass and 2 Parquet microbenchmarks skip by design. They cover the Lance suites (107: LanceTableReader* 83, LanceNestedNullTest 10, LanceRuntimeFilterHelperTest 10, LanceSessionManagerTest 4); the row-location protocol (MaterializationSharedStateTest, RowIdStorageReaderTest, and the new RowIdFetchRequestValidationTest: count mismatch, unknown version, a Lance version on an internal mapping, an internal row id of 2^32); FileScannerV2Test (28); the ORC and Parquet reader suites (NewOrcReaderTest, NewParquetReaderTest, ParquetScanTest and the other Parquet* suites), whose row ids now go through file_local; and the Arrow / protobuf / DATE serde suites with the new ArrowParentNullsTest, plus the Iceberg and Parquet reader suites that decode Arrow dates. Reverting any of the review fixes, the #68813 flag, the #68707 split rule or the #68815 bounds makes its new tests fail. The sixth review round adds 13 cases (ArrowParentNullsTest 10, LanceTableReaderTypeTest 3) and extends one; with them the Arrow SerDe, Lance, ADBC and IcebergV2ReaderTest suites run 317 cases: 310 pass and the 7 ADBC SQLite cases skip without a local SQLite driver. The ninth review round adds LanceRuntimeFilterHelperTest.KeepsInt64MinFiltersResidualOnly and LanceTableReaderFilterTest.KeepsInt64MinRuntimeFiltersOutOfLanceScanner: both pass with the 106 Lance cases (Lance*) on the branch rebased onto 339e087b11f, and restoring parse_int64() in either check makes exactly these two fail. On the head rebased onto master 315c2e00331, with the per-block mask fix (42cf4e3e5d1), 328 cases in 24 suites pass: the Arrow SerDe and validation suites, ArrowParentNullsTest (15), LoadArrowPipeTest (47), the DATETIMEV2 / TIMESTAMPTZ SerDe suites, ArrowColumnToDorisColumnTest, ArrowBlockConvertorTest, AdbcReaderTest, RemoteDorisV2ReaderTest, PaimonRustTableReaderTest and the Lance suites. Allocating at the first NULL row of each call again fails the new ArrowParentNullsTest case and the four new LoadArrowPipeTest block cases, dropping the per-batch mask fails the LIST and STRUCT block cases, and returning the child unmasked fails the new timestamp case, four ArrowParentNullsTest cases and LanceTableReaderTypeTest.IgnoresDate32PayloadHiddenByNullParents.

Release note

Add nine lance_* BE configs and lance_session_* BE metrics. They take effect once the FE can plan Lance scans. Reading an Arrow DATE32 value outside 0000-01-01..9999-12-31 through ADBC, Arrow stream load or paimon-rust now fails instead of returning 0000-00-00.

Check List (For Author)

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@morningman

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. Static review of #69581 at 6d43ec5 found five verified defects: a Decimal256 conversion crash, two search-request compatibility failures, a floating runtime-filter false negative, and silent DATE32 corruption. Every finding is inline; no existing inline threads needed carrying forward.

Critical checkpoint conclusions:

  1. Goal and proof: the BE Lance read/search/schema-fetch/lazy-fetch integration is present, but the five defects prevent a correctness-complete port. Added unit tests provide source coverage; no tests were executed in this static-only review.
  2. Scope and clarity: reader helpers, dispatch, protocol, core NULL handling and row-layout changes are related to the port; no unrelated production edit identified.
  3. Concurrency: Scanner serializes reader operations; phase-two workers retain task tracking and completion/semaphore cleanup; call_once publishes the session; entity locking serializes metric hooks and teardown. No additional verified lock-order defect.
  4. Lifecycle and static initialization: partial-creation guards and Arrow import ownership were checked; scanners close before dataset/FTS state, metrics unregister before session release, and the manager uses a function-local static. No additional verified ownership or initialization defect.
  5. Configuration: six session/cache settings require restart; three mutable scan settings are validated and sampled when each native scanner is created.
  6. Compatibility: version-zero row locations retain their 24-byte layout; uint64 varint widening preserves old ordinals, and unknown versions/sizes fail. Existing 4.x search-filter timing and distance bounds are lost. Linux glibc compatibility source/link handling was inspected; no final ELF artifact exists to audit.
  7. Thrift/protobuf: complete touched definitions were compared with live-matching branch-3.1/4.0/4.1/4.2 refs. No ID reuse, changed enum meaning or new required field found; the two omitted request options still break query semantics across upgrades.
  8. Parallel paths: normal/vector/FTS/schema/take_rows and internal/Parquet/ORC row fetching were inspected. Existing FE producer guarantees pin search snapshots, normalize split offsets, and exclude nested lazy slots. The decimal and temporal conversion defects affect the shared new Lance boundary.
  9. Conditions and errors: new structural/version/count checks have concrete failure paths. The reported decimal crash and ignored DATE32 conversion failure need explicit handling.
  10. Test coverage: substantial BE tests cover types, extensions, nulls, searches, fragments, uint64 IDs and malformed requests. Missing cases include low-precision Decimal256, out-of-range DATE32, negative-NaN probe rows, and old-FE search-option serialization. FE connector and regression suites are separate PR work.
  11. Expected results: source assertions and all 109 fixture files (99 binary files and 10 JSON hints) were inspected; no new textual regression outputs were added. Fixture generation and executable results were not independently validated.
  12. Observability: reader/search/fetch timing and cache counters plus process-wide cache metrics are present. Native/cache statistics are finalized at stream/dataset close; live execution attribution was not measured.
  13. Transactions, persistence and failover: no table-write, EditLog, transaction or MoW visibility path changes; supported search fetching uses fixed dataset snapshots.
  14. Writes and crash behavior: persistent writes are disposable cache data, not table commits. The Decimal256 finding can crash a BE while reading valid data.
  15. FE/BE variables: request producers/consumers and row-version transport were traced; prefilter and distance-bound inputs are dropped despite accepting schema_version=1.
  16. Performance and memory: schema plans and dataset-grouped takes reuse work; Arrow normalization uses its supplied pool. Native Rust/cache accounting, throughput, cancellation stress and final Linux ABI remain execution-validation limits; no build/runtime claim is made.
  17. Other concerns and user focus: no additional user focus was supplied. Remaining suspected mechanisms were independently resolved or excluded using producer/native evidence; all assigned static coverage and candidate dispositions are recorded in the review ledger.

Review converged after 3 round(s).

const PrimitiveType doris_decimal_type = precision <= 9 ? TYPE_DECIMAL32
: precision <= 18 ? TYPE_DECIMAL64
: precision <= 38 ? TYPE_DECIMAL128I
: TYPE_DECIMAL256;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Handle Decimal256 carriers before selecting a narrower SerDe

A valid Lance Decimal256(18,2) is mapped to Doris DECIMAL64 here, but normalization leaves its Decimal256Array unchanged. DataTypeDecimalSerDe<TYPE_DECIMAL64>::read_column_from_arrow casts it to arrow::DecimalArray (Decimal128Array) and then calls Value on the null result, so reading a nonempty column crashes the BE. The pinned Lance datatype encoding preserves the physical 256-bit width even when precision is <=38; current fixtures only use Decimal256 at precision 39/76. Normalize and checked-narrow that carrier, or support both physical widths in the SerDe, including nested leaves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Lance keeps the 256-bit carrier of decimal256(18, 2), while this mapping and the FE type the column DECIMAL64 by precision, so DataTypeDecimalSerDe<TYPE_DECIMAL64>::read_column_from_arrow dereferenced the null result of dynamic_cast<const arrow::DecimalArray*>; the same held for DECIMAL32/128I targets and nested leaves. Fixed in 05437fd: LanceArrowArrayNormalizer narrows a Decimal256 field with precision <= 38 to decimal128(p, s) through a safe Arrow cast, so a value outside the declared precision fails with an error naming the field, and list/struct/map parents are rebuilt around the narrowed leaf. Unit tests cover top-level, sliced nested and overflow cases and a decimal256(18, 2) Lance round trip.

Comment thread be/src/format_v2/table/lance_reader.cpp Outdated
}
// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the 4.x search-filter timing flag

The branch-4.1/4.2 FE serializes TExternalSearchRequest.prefilter=false by default, but this head omits wire field 5 and unconditionally enables prefiltering here. With top_k=1, use_index=false, and filter='keep = true', a nearest row with keep=false followed by a farther keep=true row should yield an empty postfilter result; this BE returns the farther row. The FE keeps that TVF filter only in the request, so a Doris residual cannot restore the requested behavior during an FE-4.x/BE-master upgrade. Retain field 5 with its established default and honor it when configuring the scanner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 60aebf8: this PR now ports the BE side of #68813. TExternalSearchRequest.5: optional bool prefilter has the 4.x id and comment, and both vector and full-text search pass __isset.prefilter && prefilter to lance_scanner_set_prefilter(), so unset or false means postfilter as on branch-4.1/4.2. SearchFilterDefaultsToPostfilter (from #68813) and a new full-text case cover both settings.

2: optional TSearchVector query_vector
3: optional i64 top_k
4: optional i64 offset
5: optional TVectorMetric metric

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Retain the existing vector distance-bound fields

Branch-4.1/4.2 already define fields 6/7 as distance_lower_bound/distance_upper_bound. Their FE writes these from vector_search properties without synthesizing a Doris residual. This definition discards both fields during Thrift deserialization, and the new reader never applies a native distance range while still accepting schema_version=1. During an FE-4.x/BE-master upgrade, an exact top-1 query whose nearest squared L2 distance is 1 and distance_upper_bound=0.5 therefore returns that row instead of an empty result. Preserve the IDs and enforce the range, or explicitly reject unsupported bounded requests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 7edd9cc: TVectorSearchParams 6/7 (distance_lower_bound / distance_upper_bound) have the 4.x ids and types, the request is validated as in #68815 (finite FLOAT bounds, lower < upper at FLOAT precision, no multi-vector), and lance_scanner_set_distance_range() is installed after nearest(), using master's pinned lance-c. #68815's fallback for quantized indexes relies on the BE searching a split without index segments flat, which came from #68707, so a66c2a6 ports that rule (use_index follows the split's segments; a split with segments under use_index=false is rejected). #68815's four tests and fixtures come along unchanged.

return std::nullopt;
}
const auto* sql_operator = predicate.op() == TExprOpcode::GE ? ">=" : "<=";
return "(" + quote_sql_identifier(slot->column_name()) + " " + sql_operator + " " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve negative NaNs when pushing floating range filters

For a nested-loop join L.x > B.y with finite B.y=1, FE can generate the MIN runtime filter L.x >= 1. A Lance FLOAT containing negative quiet-NaN bits 0xffc00000 satisfies both predicates in Doris, whose Compare treats every NaN as greatest. The pinned Arrow floating comparison uses total ordering, placing negative NaNs below finite values, and DataFusion delegates GE directly to that kernel; this generated SQL therefore drops a matching probe row. Rejecting nonfinite bound literals does not protect against NaN input values. Keep floating range filters residual-only or widen them to preserve every Doris survivor, with differential negative-NaN coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Doris keeps every NaN for x >= c (GreaterThanEqualsFloat treats NaN as greatest regardless of sign), while the pinned Arrow/DataFusion kernels use totalOrder, so a MIN-only filter could drop a negative-NaN row that Doris returns. <= and IN are safe: Lance only returns extra negative NaNs for <=, which the residual removes, and IN sets with NaN members are not pushed. Fixed in bf5dcbd: FLOAT/DOUBLE >= runtime filters stay residual-only, the policy PaimonRustPredicateConverter already applies, with a helper test and a reader test on 0xffc00000 / 0xfff8000000000000 rows. branch-4.1 and branch-4.2 carry the same helper.

RETURN_IF_ERROR(
_array_normalizer_for_arrow_column[static_cast<size_t>(arrow_index)]
.normalize_for_doris(arrow_column, _memory_pool, &normalized_column));
RETURN_IF_ERROR(output.serde->read_column_from_arrow(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Check DATE32 conversion failures at the Lance boundary

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, though the defect is in the shared DataTypeDateV2SerDe::read_column_from_arrow DATE32 branch: master has ignored the get_date_from_daynr() result there since #48944, so ADBC, Arrow stream load and paimon-rust return the same non-null 0000-00-00. branch-4.1/4.2 reject these values in the SerDe (#68649), and the 4.2 Lance code relies on that. Fixed in 6714c7f with the DATE32 range part of #68649: null slots are skipped, the day number is computed in 64 bits, and an out-of-range day returns InvalidArgument like the DATE64 branch next to it, which also covers ARRAY/STRUCT/MAP leaves.

@hello-stephen

Copy link
Copy Markdown
Contributor

Cloud UT Coverage Report

Increment line coverage 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 78.02% (2080/2666)
Line Coverage 66.27% (38352/57868)
Region Coverage 53.81% (36079/67054)
Branch Coverage 57.31% (11621/20276)

morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
…er.h

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary: BE configure fails the build-hygiene gate on this PR
(COMPILE and BE UT): util/threadpool.h now reaches 1088 TUs, one over
its limit of 1087 (baseline 988 +10%). Master reaches 1083. Five of the
new Lance TUs reach the header through exprs/vexpr_context.h ->
storage/segment/column_reader.h -> io/cache/cached_remote_file_reader.h
-> io/cache/block_file_cache.h, an existing chain this PR does not
touch.

One of them did not need to. lance_record_batch_converter.h included
the whole format_v2/table_reader.h for two structs, ColumnDefinition and
GlobalRowIdContext, which are defined in format_v2/column_data.h, and
that header reaches nothing on the chain. The converter header now
includes column_data.h instead, so lance_record_batch_converter.cpp no
longer reaches util/threadpool.h and the reach is 1087, within the
limit. No gate table changes. The other four TUs (lance_reader.cpp,
lance_runtime_filter_helper.cpp and their unit tests) evaluate conjuncts
and runtime filters, so they include vexpr_context.h on purpose;
lance_reader.h still gets table_reader.h directly.

### Release note

None

### Check List (For Author)

- Test: Manual test
    - build-support/check-build-hygiene.sh passes (threadpool.h reach
      1087 of 1087).
    - With the BE UT compile flags, clang++ -fsyntax-only passes on
      lance_record_batch_converter.cpp, on a TU that includes only its
      header, and on the unity batch that holds it.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68649

Problem Summary:

Reproduction: read an Arrow Date32 array holding a day outside
0000-01-01..9999-12-31 into a Doris DATE column, for example 2932897
(10000-01-01) written by PyArrow into a Lance dataset, or the same value
returned by an ADBC driver, an Arrow stream load or the paimon-rust reader.
The row comes back as a non-null `0000-00-00`, also inside ARRAY, STRUCT and
MAP values.

Root cause: the DATE32 branch of the shared
`DataTypeDateV2SerDe::read_column_from_arrow` ignores the bool returned by
`get_date_from_daynr()` (dropped since apache#48944) and appends the
default-constructed value. It also adds the epoch offset in 32 bits, so
INT32_MAX overflows, and it decodes the payload of NULL slots. The DATE64
branch next to it already rejects out-of-range days.

Fix: port the DATE32 range part of the branch-4.1/4.2 fix apache#68649. NULL slots
append the default value without being decoded, the day number is computed
in 64 bits, and a day number <= 0, above DATE_MAX_DAYNR or rejected by
`get_date_from_daynr()` returns `InvalidArgument("Arrow Date32 value is
outside the Doris DATE range: row=..., days=...")` with the 4.x wording.
ARRAY/STRUCT/MAP leaves get the same check through their element SerDes.
`write_column_to_arrow` and the year-zero calendar mapping (the other part of
apache#68649, and master draft apache#67449) are unchanged; the new tests avoid the days
whose meaning that mapping changes.

Before: out-of-range days read as `0000-00-00`. After: the read fails with
the error above; in-range values and NULLs read as before.

The Lance reader ported in apache#69581 (from the branch-4.2 snapshot, which
already contains apache#68649) relies on this SerDe check.

### Release note

Reading Arrow DATE32 values outside 0000-01-01..9999-12-31 (ADBC, Arrow
stream load, paimon-rust, Lance) now fails instead of returning 0000-00-00.

### Check List (For Author)

- Test: Unit Test
    - New: `DataTypeDateTimeV2SerDeTest.RejectsInvalidDate32Values`,
      `LanceTableReaderTypeTest.RejectsDate32OutsideDorisDateRange`,
      `LanceTableReaderTypeTest.ReadsLastDorisDateAndIgnoresNullDate32Payload`.
    - All pass on macOS arm64 (ASAN), together with `DataTypeDateTimeV2SerDeTest.*`,
      `DataTypeSerDeArrowTest.*`, `DataTypeSerDeArrowValidationTest.*`,
      `ArrowColumnToDorisColumnTest.*`, `ParquetExprTest.*`,
      `IcebergV2ReaderTest.*` and the Lance suites. With master's DATE32 branch
      restored, the first two fail.
    - `DataTypeDateTimeV2SerDeTest` aborts in its setup under macOS ASAN on
      master: `load_data_from_csv()` in `be/test/testutil/test_util.cpp`
      dereferences `std::set::end()`. It ran here with that assertion removed
      locally; that fix goes in a separate PR.
- Behavior changed: Yes. An Arrow DATE32 day outside 0000-01-01..9999-12-31
  used to be returned as a non-null 0000-00-00; the scan now fails with an
  InvalidArgument error, as Arrow DATE64 values already did.
- Does this need documentation: No

Co-authored-by: Gabriel <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
…decoding

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: write a Lance dataset with a `pa.decimal256(18, 2)` column and
scan it with the Lance reader added in apache#69581. Lance keeps the 256-bit width
on disk, so the scanner returns an Arrow `Decimal256Array`, and the BE crashes
on the first non-empty batch. The same happens for any `decimal256(p, s)` with
p <= 38, also as a list, large_list, fixed_size_list, struct or map leaf. The
existing fixture only has decimal256 columns with precision 39 and 76, which
is why the tests did not catch it.

Root cause: `arrow_field_to_doris_type()` and the FE pick the Doris type from
the precision alone (DECIMAL32 up to 9, DECIMAL64 up to 18, DECIMAL128I up to
38), while `LanceArrowArrayNormalizer` passes the `Decimal256Array` through
unchanged. `DataTypeDecimalSerDe<TYPE_DECIMAL32/64/128I>::read_column_from_arrow`
casts the array with `dynamic_cast<const arrow::DecimalArray*>`, gets null for
a `Decimal256Array`, and dereferences it in `Value()`.

Fix: at schema binding, `LanceArrowArrayNormalizer::create()` records
`decimal128(p, s)` for a Decimal256 field with p <= 38 and marks it as needing
special handling, which parents inherit through the existing child loop.
`normalize_for_doris()` then narrows the array with
`arrow::compute::Cast(..., CastOptions::Safe())` on the query's Arrow memory
pool, the cast master's ADBC normalizer already uses
(`format/arrow/arrow_array_normalizer.cpp`). Nested parents rebuild their type
around the narrowed child through `set_lance_nested_type()`, as for BFloat16
leaves, before compaction. A value outside the declared precision, which is
invalid Arrow data (for example unscaled 10^18 in decimal(18, 2)), fails with
`narrow Lance Decimal256 field '<name>' to decimal128(18, 2) failed: Decimal
value does not fit in precision 18` instead of being truncated. Precision
39..76 keeps Decimal256 and DECIMAL256 as before.

Before: the BE dereferences a null pointer and crashes. After: the values are
read unchanged, and an out-of-precision value fails with an error that names
the field.

branch-4.1 and branch-4.2 have the same normalizer, so the same change applies
there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderTypeTest.NarrowsDecimal256WithDecimal128PrecisionDuringNormalization`,
      `LanceTableReaderTypeTest.KeepsWideDecimal256UnchangedDuringNormalization`,
      `LanceTableReaderTypeTest.ConvertsDecimal256WithDecimal128PrecisionToDorisDecimals`,
      `LanceTableReaderTypeTest.NarrowsDecimal256LeavesInSlicedNestedArrays`,
      `LanceTableReaderTypeTest.RejectsDecimal256ValuesOutsideDeclaredPrecision`,
      `LanceTableReaderTypeTest.ReadsDecimal256WithDecimal128PrecisionFromLanceDataset`
      (writes a decimal256(18, 2) dataset, checks that Lance stores Decimal256 and
      that the reader returns DECIMAL64 values).
    - All pass on macOS arm64 (ASAN) with the Lance suites. With the narrowing
      disabled, the normalization cases fail and the sliced nested case then
      crashes in `DataTypeDecimalSerDe::read_column_from_arrow`, as reported.
- Behavior changed: No (fixes a crash in the reader this PR adds)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: scan a Lance FLOAT or DOUBLE column that holds a negative NaN
(float bits 0xffc00000, double bits 0xfff8000000000000) under a runtime filter
`x >= c`, for example a MIN-only filter from a nested-loop join on `x >= y`.
Doris treats every NaN as the greatest value regardless of its sign
(`GreaterThanEqualsFloat` in `common/compare.h`), so the row satisfies the
filter, but it is missing from the result.

Root cause: `build_range_filter_sql()` translates the filter into the Lance
SQL `(x >= c)`. Lance evaluates it with Arrow/DataFusion comparison kernels
that order floating values by IEEE 754 totalOrder, where a negative NaN sorts
below every finite value, so Lance drops the row before the scanner evaluates
the Doris residual. The residual can only remove rows, not bring them back.

Fix: `build_range_filter_sql()` returns `std::nullopt` for a `>=` filter whose
slot is FLOAT or DOUBLE, so the filter stays residual-only and is reported
under `LanceRuntimeFilterSkippedIds`, the policy `PaimonRustPredicateConverter`
already applies to floating predicates. `<=` filters are still pushed: Lance
then only returns extra negative-NaN rows, which the residual removes. IN
filters are still pushed: a NaN row never matches a finite member, and sets
with NaN members were already rejected. A MIN_MAX filter now pushes only its
`<=` half.

Before: rows holding a negative NaN were dropped under a floating `>=` runtime
filter. After: Lance returns them and the Doris residual decides, so the
result matches a scan without the pushed filter.

branch-4.1 and branch-4.2 carry the identical helper, so the same change
applies there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceRuntimeFilterHelperTest.KeepsFloatingMinFiltersResidualOnly`
      (`>=` skipped, `<=` and IN still pushed, SQL of a MIN_MAX pair),
      `LanceTableReaderFilterTest.PreservesNegativeNaNUnderFloatingMinRuntimeFilter`
      (FLOAT and DOUBLE rows 0.5, 1, 2, +NaN, -NaN under `>= 1`; every row Doris
      keeps must come back from Lance).
    - Both pass on macOS arm64 (ASAN) with the Lance suites; with
      `build_range_filter_sql()` as before this change, both fail.
- Behavior changed: No (the Lance reader is added by this PR; floating `>=`
  runtime filters are no longer pushed into Lance, so such scans may read more
  rows)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
…file as in branch-4.x

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68813

Problem Summary:

apache#69581 ports the Lance reader from the branch-4.2 snapshot cabcf45. After
that snapshot, apache#68813 changed two things in the 4.x BE, both ported here.

1. Search filter timing. apache#68813 added `TExternalSearchRequest.prefilter`
   (field 5): unset or false means the TVF search filter refines the top-k
   candidates (postfilter), true means it constrains them before top-k
   (prefilter). The 4.x FE sends it for `vector_search()` and
   `full_text_search()`. Without the field, a master BE always prefiltered
   vector search and never prefiltered full-text search, so a 4.x FE paired
   with it got different rows: on the all_types.lance fixture, top_k 1 with
   search filter `row_id >= 3` returns row 4 with prefilter and no row with
   postfilter.
   - `gensrc/thrift/PlanNodes.thrift`: `5: optional bool prefilter` with the
     4.x comment; the stale `TSearchFilter` comment is replaced.
   - `_configure_vector_search()` and `_configure_full_text_search()` pass
     `request.__isset.prefilter && request.prefilter` to
     `lance_scanner_set_prefilter()`, replacing the comment that
     fragment-scoped nearest queries need prefiltering (master's lance-c pin
     4fafcd64525 no longer does).
   - Compatibility (gensrc/AGENTS.md): field 5 is `optional bool prefilter` on
     the branch-4.2 (a7ae6f4) and branch-4.1 (3f911d0) heads;
     branch-4.0 and branch-3.1 have no `TExternalSearchRequest`. No id is
     reused.

2. Profile layout. The snapshot hangs about 45 counters directly under
   `LanceReader`, looks Lance's scan metrics up in two maps, and creates two
   scalar-index timers from Lance's statistics callback the first time they
   are reported, that is from Lance's execution threads while the scan runs.
   As in apache#68813:
   - The counters are grouped under `LanceTiming`, `LanceIO`, `LanceIndex`,
     `LanceVectorIndex`, `LanceScalarIndex`, `LanceExecutionPlan`,
     `LanceScan`, `LanceSearchPlan` and `LanceDataCache` (label counters,
     `TUnit::NONE`, like `RuntimeFilterInfo`).
   - One immutable table, `LANCE_SCAN_METRICS` in `lance_reader_helper.h`,
     drives both registration and the callback lookup. Every metric is
     registered in `_init_scanner_profile()` before the callback is installed,
     so the callback only updates existing counters and every split has the
     same counter tree.
   - The callback is `noexcept` and swallows exceptions, so nothing unwinds
     through the lance-c ABI.
   - `LanceIndexObjectLoads` (`indices_loaded`), `LanceIndexCacheHits` and
     `LanceIndexCacheMisses` are new; the info strings `LanceTopKPlusOffset`
     and `LanceSubstraitFilterBytes` are dropped.
   - Renames: `LanceScannerReadTime` -> `LanceScannerNextTime`,
     `LanceRuntimeFilterSqlTime` -> `LanceRuntimeFilterSqlTranslationTime`,
     `LanceExecutionIOOps` / `IORequests` / `IOBytesRead` -> `LanceIOReadOps` /
     `LanceIOReadRequests` / `LanceIOReadBytes`,
     `LanceIndexPartitionCacheMissLoads` -> `LanceIndexComponentsLoaded`,
     `LancePrefilter*` -> `RowIdPrefilter*` (`LancePrefilterRowIds` ->
     `RowIdPrefilterIds`), `LanceIVFPartitionsRanked` ->
     `LanceIVFPartitionsConsidered`, `LanceIVFPartitionsSearched` ->
     `LanceIndexPartitionsSearched`, `LanceVectorIndexSegmentsSearched` ->
     `LanceVectorIndexDeltasSearched`, `LanceTaskWaitTime` ->
     `LanceScanTaskWaitTime`, `LanceIVFPartitionRankingTime` ->
     `LanceIVFPartitionSelectionTime`, `LanceScalarIndexSegmentPrepareTime` ->
     `LanceScalarIndexCandidateEvaluationTime`.
   - Master differs from 4.x in one line: the callback asserts with
     `DCHECK_LT` that a found metric has a registered counter, where 4.x
     checks it in the `if`; both come from the same array.
   - Not ported: apache#68813's FE change that makes `AggCounter` print nothing for
     `TUnit.NONE`. It belongs to the FE port; until then master's merged
     profile prints the group labels like the existing `RuntimeFilterInfo`.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderVectorSearchTest.SearchFilterDefaultsToPostfilter`
      (from apache#68813) and `LanceTableReaderFullTextSearchTest.SearchFilterHonorsPrefilterFlag`
      (top_k 1 with filter `row_id <= 2`: no row with postfilter, row 2 with
      prefilter; 4.x has no BE test for the full-text path).
    - Changed as in apache#68813: `AppliesSearchFilterBeforeTopK` becomes
      `AppliesSearchFilterBeforeTopKWhenPrefilterEnabled`;
      `MultiVectorScoresFiltersOffsetsAndIndexedCoverage` and
      `MultiVectorSegmentTopKMatchesIndependentGlobalOracle` set `prefilter`
      for their filtered cases, and the former checks the `RowIdPrefilter*`
      counters and that a flat split exposes `LanceIndexPartitionLoadTime`.
    - All pass on macOS arm64 (ASAN) against master's pinned lance-c,
      with the Lance suites. With vector search always prefiltering and
      full-text search never prefiltering, the two new cases fail.
- Behavior changed: Yes, on the Lance path added by apache#69581. A search filter
  without `prefilter` now postfilters vector search, and full-text search
  honors `prefilter = true`, as on branch-4.1/4.2. Lance profile counters are
  grouped and renamed. Master has no FE that plans Lance scans yet.
- Does this need documentation: No

Co-authored-by: zhangstar333 <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
… planned

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68707, apache#68815

Problem Summary:

apache#69581 ports the Lance reader from the branch-4.2 snapshot cabcf45, where
`_configure_vector_search()` passes the request's `use_index` to
`lance_scanner_set_use_index()`. FE decides per split whether to use an index:
a split it plans with index segments carries their UUIDs, and a split without
them is meant to be searched flat. With `use_index = true` and no segments,
Lance instead searches the split with the first index on the column in
manifest order. That index need not be the one FE chose: when FE finds no
index whose metric matches the query, it plans every split flat, and the
snapshot BE still searched them with the mismatched index. On branch-4.2,
apache#68815 also plans distance-range searches over quantized indexes (IVF_SQ) as
flat fallback splits, because quantized distances can drop rows inside the
range; the snapshot BE searches those with the index and returns too few rows.

Fix: port the split-level rule of apache#68707.
- `lance_scanner_set_use_index(scanner, segment_count > 0)`: a split with
  segments searches exactly those, a split without segments searches flat,
  and `LancePlannedFlatSearchFragmentCount` now matches what Lance does.
- A split that carries index segments while the request sets
  `use_index = false` is rejected with InvalidArgument. FE plans segments only
  when `use_index` allows them, on branch-4.1 (4.1.4 onward) and branch-4.2
  alike, so a 4.x FE never sends this combination.
- Not ported: the rest of apache#68707 (searching historical versions, tags and
  branches; rejecting fragment ids missing from the snapshot; naming the
  snapshot in errors and the profile). That comes with the next 4.x catch-up.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New, from apache#68707 (only master's `RuntimeState(TQueryOptions(), globals)`
      constructor differs): `LanceTableReaderVectorSearchTest.SplitWithoutIndexSegmentsSearchesFlat`,
      `LanceTableReaderVectorSearchTest.RejectsIndexSegmentsWhenUseIndexIsFalse`.
    - Changed as in apache#68707: `MultiVectorScoresFiltersOffsetsAndIndexedCoverage`,
      `MultiVectorTopOnePreservesPrecisionAndBatchIndependence` and
      `MultiVectorCosineMasksUndefinedRows` pass the index segments for their
      indexed cases (new helper `make_indexed_lance_range`) and check
      `LanceIndexPartitionsSearched`; `MultiVectorDefaultMetricIsConsistentAcrossFragments`
      checks that every split is searched flat.
    - All pass on macOS arm64 (ASAN) against master's pinned lance-c,
      with the Lance suites. With `use_index` taken from the request and the
      rejection removed, the two new cases fail.
- Behavior changed: Yes, on the Lance path added by apache#69581: a vector split
  without index segments is searched flat even when `use_index` is true, and a
  split with segments under `use_index = false` is rejected. Master has no FE
  that plans Lance scans yet.
- Does this need documentation: No

Co-authored-by: zy-kkk <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68815

Problem Summary:

apache#69581 ports the Lance reader from the branch-4.2 snapshot cabcf45. After
that snapshot, apache#68815 added `TVectorSearchParams.distance_lower_bound` (6) and
`distance_upper_bound` (7) to branch-4.1 and branch-4.2: FLOAT bounds,
inclusive lower and exclusive upper, which the 4.x FE fills for a
single-vector `vector_search()` with distance bounds. apache#68815 kept the
external-search `schema_version` at 1, so a BE cannot tell a bounded request
from an unbounded one by version. A master BE without the fields ignored them
and returned the unbounded top-k rows, for example rows with `_distance`
below the lower bound.

Fix: port the BE side of apache#68815.
- `gensrc/thrift/PlanNodes.thrift`: add `6: optional double
  distance_lower_bound` and `7: optional double distance_upper_bound` to
  `TVectorSearchParams` with the 4.x comment.
- `LanceTableReader::_validate_external_search_request()` rejects bounds on a
  multi-vector query (NotSupported), a bound that is not a finite FLOAT value
  (NaN, infinity, or beyond FLT_MAX), and a lower bound that is not below the
  upper bound at FLOAT precision (InvalidArgument), before the dataset is
  opened.
- `_configure_vector_search()` narrows the bounds to FLOAT and installs them
  with `lance_scanner_set_distance_range()` after `lance_scanner_nearest()`,
  which clears any earlier range. A missing bound is passed as NULL
  (unbounded on that side). Requests without bounds are unchanged.

Before: distance bounds sent by a 4.x FE were silently ignored. After: the
reader returns only rows with lower <= _distance < upper, still capped by
top_k and offset, and rejects invalid bounds.

Compatibility (gensrc/AGENTS.md): fields 6 and 7 of `TVectorSearchParams` are
`optional double distance_lower_bound` and `optional double
distance_upper_bound` on the branch-4.2 (a7ae6f4) and branch-4.1
(3f911d0) heads, the same ids and types as here. branch-4.0 and
branch-3.1 have no `TVectorSearchParams`, and on master the struct is new in
apache#69581 with fields 1-5. No id is reused, and both fields are optional.

`lance_scanner_set_distance_range()` is in master's lance-c pin
(4fafcd64525), so only the BE changes.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New (copied unchanged from apache#68815 except for master's
      `RuntimeState(TQueryOptions(), globals)` constructor):
      `LanceTableReaderVectorSearchTest.ValidatesDistanceRangeBeforeDatasetAccess`,
      `LanceTableReaderVectorSearchTest.AppliesDistanceBoundsAndOffset`,
      `LanceTableReaderVectorSearchTest.DistanceRangeFallbackPreservesExactScores`,
      `LanceTableReaderVectorSearchTest.DistanceRangeCoversIndexedAndAppendedFragments`.
    - Fixtures copied from d306323 (identical on the branch-4.2 head):
      `be/test/format_v2/table/lance/data/distance_range.lance` (11 files),
      `distance_range_dot.lance` (8 files), `distance_range_sq.lance` (8
      files) and their description
      `be/test/format_v2/table/lance/distance_range_fixture.md`.
    - All pass on macOS arm64 (ASAN) against master's pinned lance-c, with the
      Lance suites. `DistanceRangeFallbackPreservesExactScores` needs the
      previous commit: without it the IVF_SQ fallback split is searched with
      the quantized index and loses its only row. With the range not installed,
      the three cases that read rows fail.
- Behavior changed: Yes, on the Lance external-search path added by apache#69581:
  distance bounds are applied instead of ignored, and invalid bounds are
  rejected. Master has no FE that sends Lance search requests yet.
- Does this need documentation: No

Co-authored-by: Gabriel <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
morningman added a commit to morningman/doris that referenced this pull request Oct 25, 2026
…Lance reader

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

BE configure runs `build-support/check-build-hygiene.sh`. Its reverse reach
budget caps how many TUs may transitively include `util/threadpool.h` at the
baseline 988 (master 9a48f81, 2026-08-18) plus 10%, that is 1087. Master
76fb880 is at 1084, and the Lance reader adds four TUs that reach it
(`format_v2/table/lance_reader.cpp`,
`format_v2/lance/lance_runtime_filter_helper.cpp` and their unit tests), so
configure on master plus this PR fails with "util/threadpool.h now reaches
1088 TUs (baseline 988 +10% = 1087)".

The four TUs add no new edge. They need `VExprContext`, directly or through
`format_v2/table_reader.h`, and `exprs/vexpr_context.h` holds
`segment_v2::ColumnIteratorOptions` by value, so it includes
`storage/segment/column_reader.h` -> `io/cache/cached_remote_file_reader.h` ->
`io/cache/block_file_cache.h` -> `util/threadpool.h`, the path the other
format_v2 readers already take. Narrowing includes inside the Lance files
cannot remove it; the record batch converter header was already narrowed to
`format_v2/column_data.h` in this PR.

As the gate's message prescribes, this rebaselines `util/threadpool.h` to the
measured 1088 (limit 1197) and records the reason next to the entry. Cutting
the `vexpr_context.h` -> `column_reader.h` edge, by moving
`ColumnIteratorOptions` into a light header, would bring the reach down to
about 846; that is a separate build cleanup.

### Release note

None

### Check List (For Author)

- Test: No need to test: build configuration only.
  `bash build-support/check-build-hygiene.sh` passes on this branch and on
  master 76fb880 plus this PR.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@morningman

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two new P2 findings remain: out-of-range Lance timestamps can silently wrap their year, and persistent native session caches have no Doris tracking/reclamation bridge. Both are reported inline.

The five existing findings were independently rechecked at a1c3636: Decimal256 narrowing (4369909216), search prefilter timing (4369909218), vector distance bounds (4369909220), negative-NaN GE filters (4369909223), and DATE32 range checking (4369909226) are fixed. No existing P0/P1 finding remains applicable, so none is reposted or carried as blocking. The new timestamp finding follows a separate TIMESTAMP/DATETIMEV2 conversion path from the repaired DATE32 issue. There was no additional user-provided focus.

Critical checkpoint conclusions:

  1. Purpose and proof: the native Lance BE scan/schema/search/fetch port is connected through FileScannerV2. Extensive unit-test code is present; timestamp boundaries and persistent-cache reclamation still need coverage for the inline defects. Tests were inspected, not executed.
  2. Scope and reuse: the reader, conversion, filter and session helpers form a coherent BE port; shared Null/DATE32 SerDes and row-ID width changes have corresponding callers and tests.
  3. Concurrency and locks: parallel scanners share the call_once-initialized session; callbacks update pre-registered counters. Phase-two tasks retain per-task outputs and completion ownership. No additional concrete race or lock inversion was found.
  4. Lifecycle/static initialization: scanner cleanup precedes FTS context/dataset cleanup; partial-open guards and schema resets preserve ownership. The function-local session and synchronized metric-hook teardown avoid an established static-lifetime defect; cache retention is covered inline.
  5. Configuration: six cache settings are process-scoped; the three mutable buffer/readahead settings are sampled for newly created scanners. Existing scanners are not promised immediate updates.
  6. Compatibility: the 24-byte version-0 layout and original uint32 row-ID values are preserved; version 1 carries native uint64 Lance IDs, and current receivers reject unknown versions. Old receivers cannot decode version 1: the explicitly deferred FE rollout gate is still needed before enabling mixed-version Lance lazy materialization.
  7. Thrift/protobuf: every touched definition was compared with live-verified branch-3.1/4.0/4.1/4.2 snapshots. Existing IDs, enum meanings and optional search fields match 4.1/4.2; older branches lack the new search definitions. Legacy protobuf row IDs retain their wire meaning and range.
  8. Parallel paths: normal/vector/FTS, schema-only, nested types, row-ID take/scatter and legacy Parquet/ORC paths were checked. FE split requests reset search offset after increasing candidate top_k, explaining the different split-level offset handling.
  9. Conditions and errors: FFI failures, malformed versions/counts and unsupported filter/type cases return errors or retain residual evaluation. Lance's pinned planner repairs signed-zero comparisons; raw DataFusion behavior alone is not a defect here.
  10. Test coverage: positive/negative tests cover types, NULLs, slicing, indexes, search bounds, prefilter and row-ID validation. Full scanner/metadata-count/RPC integration, cancellation, broader writer interoperability and live resource behavior were not established by execution.
  11. Expected results: no regression .out files changed. Binary fixture inventories and their test expectations were inspected; their runtime output was not independently reproduced.
  12. Observability: scan, index, cache, search and fetch metrics/timers are present; metric gauges do not provide the missing cache reclamation hook.
  13. Transactions/persistence: no Doris EditLog or transaction-state mutation is introduced; snapshot identity is carried into phase-two fetch.
  14. Writes/atomicity: the production change is a read path. No new Doris write/commit protocol or committed-row visibility defect was identified.
  15. FE/BE passing: search fields and split contracts match current 4.x senders; the master FE connector/rollout policy is outside this BE-only port.
  16. Performance/memory: session/schema reuse, zero-copy common arrays and deduplicated snapshot-level take_rows avoid redundant work. Persistent cache tracking/reclamation remains an inline issue. No benchmark or memory-pressure experiment was run.
  17. Further sweep: adjacent timestamp conversion, complete upstream floating-filter handling, real Rust Arrow export layouts, RF cache scope, recursive fragment rebuilds, native take ordering and cleanup were rechecked. No other verified finding remains.

Static review only: no builds, tests, source modifications or submodule initialization were performed. Existing glibc compatibility adapters/link ordering were inspected, but no completed Linux ELF artifacts were available to verify the GLIBC_2.17 release baseline. These execution/artifact and deferred-FE limits are not claimed as validated behavior. All code-review candidates and risk focuses have a recorded disposition; no unresolved candidate is being submitted as a finding.

Review converged after 2 round(s).

RETURN_IF_ERROR(
_array_normalizer_for_arrow_column[static_cast<size_t>(arrow_index)]
.normalize_for_doris(arrow_column, _memory_pool, &normalized_column));
RETURN_IF_ERROR(output.serde->read_column_from_arrow(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject out-of-range timestamps before narrowing the year

A valid Lance timestamp(us) value 2005980768000000000 represents 65537-01-01 00:00:00, but this call returns non-null 0001-01-01. The schema mapper selects DATETIMEV2(6), and the shared timestamp SerDe calls from_unixtime(), which passes the civil year to unchecked_set_time(uint16_t year, ...), narrowing 65537 to 1. transform_date_scale(6, 6) only copies, and NullableSerDe retains the source's non-null bit. The repaired DATE32 check is on another conversion path and cannot catch this, including nested timestamp leaves. Validate the calendar range before narrowing and add first-out-of-range and year-wrap timestamp cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it is not specific to Lance: DataTypeDateTimeV2SerDe::read_column_from_arrow packs the civil year from from_unixtime() into unchecked_set_time(uint16_t year, ...) without a range check, so ADBC, Arrow stream load, paimon-rust and remote Doris reads return the same 0001-01-01 for 2005980768000000000 us, and years 10000..65535 come back as non-null values outside the DATETIME range. The TIMESTAMPTZ Arrow path and the UTC-adjusted Parquet paths call is_valid_date() only after packing, which a wrapped year passes. branch-4.1 and branch-4.2 have the same code. Unlike the DATE32 check in this PR, which ports a fix branch-4.1 / 4.2 already have (#68649), this one is missing on every branch, and the change belongs in the shared SerDe used by all of those readers. It goes in a separate PR that checks the civil year before narrowing, as the ORC path does since #68516, for the Arrow and Parquet timestamp paths, with first-out-of-range, year-wrap, pre-0000 and nested cases. This PR does not touch that SerDe.

.read_block_size_bytes =
static_cast<uint64_t>(_config.lance_data_cache_read_block_size_bytes),
};
_session = lance_session_new_with_data_cache(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make persistent session caches trackable and reclaimable

This singleton gives the native index and metadata caches 10 GiB and 1 GiB by default, but neither cache is charged to a Doris MemTracker or registered with CacheManager. The pinned native session retains these caches after dataset/query teardown; publishing size gauges does not add a reclaim path. Once indexed reads populate them, Doris's memory-pressure capacity adjustment and /api/clear_cache/all only visit registered CachePolicy objects, leaving these idle entries live even while other queries encounter process memory pressure. Disabling the Foyer data cache does not disable these two caches. Add process-cache accounting and a CacheManager eviction/capacity bridge, and cover reclamation after the owning queries finish.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed; this is the branch-4.1 / 4.2 design, ported unchanged. The index and metadata caches belong to the lance-c session, which a BE creates on its first Lance scan with the configured capacities (lance_index_cache_size_bytes, lance_metadata_cache_size_bytes, applied at restart); enable_lance_data_cache = false does not affect them, and the lance_session_* metrics report their usage. The pinned lance-c exposes only lance_session_get_cache_stats(). It has no call to clear, evict or resize either cache, so a CachePolicy could neither prune them nor follow the memory-pressure capacity adjustment. Making them reclaimable needs that API in lance-c first and should land on all branches together, so it is tracked as a follow-up. The PR description now says that these caches are not charged to a MemTracker or managed by CacheManager, and how to size them.

@hello-stephen

Copy link
Copy Markdown
Contributor

Cloud UT Coverage Report

Increment line coverage 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 78.02% (2080/2666)
Line Coverage 66.26% (38345/57868)
Region Coverage 53.65% (35972/67054)
Branch Coverage 57.23% (11603/20276)

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28563 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit a1c36365694010ca74a1870337f16890751708a5, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17375	5186	5073	5073
q2	2154	302	246	246
q3	10216	1401	823	823
q4	4683	463	338	338
q5	7456	802	536	536
q6	192	186	152	152
q7	751	800	595	595
q8	9271	1351	1134	1134
q9	5628	4218	4223	4218
q10	6812	1297	1043	1043
q11	435	242	229	229
q12	638	409	289	289
q13	18110	3537	2994	2994
q14	279	277	255	255
q15	q16	739	712	662	662
q17	1179	746	595	595
q18	6974	6189	6142	6142
q19	1126	976	615	615
q20	371	337	223	223
q21	4698	2143	2306	2143
q22	352	304	258	258
Total cold run time: 99439 ms
Total hot run time: 28563 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5635	5563	5560	5560
q2	678	579	549	549
q3	5239	5395	5026	5026
q4	1999	2154	1539	1539
q5	5333	5253	5653	5253
q6	269	208	157	157
q7	2287	2015	1869	1869
q8	3055	2716	2750	2716
q9	8148	7954	7932	7932
q10	3722	3691	3216	3216
q11	614	586	417	417
q12	721	681	544	544
q13	3537	3784	3241	3241
q14	328	343	325	325
q15	q16	708	732	641	641
q17	8312	7346	6957	6957
q18	13024	12236	12916	12236
q19	923	787	777	777
q20	2140	2181	1903	1903
q21	5627	4593	4683	4593
q22	505	441	427	427
Total cold run time: 72804 ms
Total hot run time: 65878 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 150868 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit a1c36365694010ca74a1870337f16890751708a5, data reload: false

query5	4294	573	437	437
query6	434	223	185	185
query7	4798	441	245	245
query8	312	172	141	141
query9	8774	3087	3043	3043
query10	462	304	254	254
query11	5960	3846	3559	3559
query12	150	92	86	86
query13	1272	413	316	316
query14	6135	3948	3632	3632
query14_1	3405	3378	3417	3378
query15	208	196	180	180
query16	966	423	414	414
query17	860	667	513	513
query18	2436	426	310	310
query19	208	168	146	146
query20	86	84	81	81
query21	210	124	104	104
query22	13007	12892	12728	12728
query23	13484	12864	12516	12516
query23_1	12628	12603	12610	12603
query24	7341	810	466	466
query24_1	460	486	467	467
query25	504	381	336	336
query26	1248	255	141	141
query27	2763	441	272	272
query28	4647	1939	1946	1939
query29	1533	584	436	436
query30	296	226	186	186
query31	1011	930	815	815
query32	138	91	92	91
query33	513	296	222	222
query34	883	850	473	473
query35	728	741	638	638
query36	883	804	739	739
query37	117	96	81	81
query38	1767	1729	1659	1659
query39	808	771	750	750
query39_1	718	724	742	724
query40	215	113	93	93
query41	68	66	61	61
query42	88	83	80	80
query43	336	359	319	319
query44	1299	696	692	692
query45	179	175	164	164
query46	842	964	573	573
query47	2897	2873	2707	2707
query48	305	316	223	223
query49	580	430	306	306
query50	689	281	206	206
query51	10516	10264	10384	10264
query52	80	82	71	71
query53	191	208	166	166
query54	258	213	186	186
query55	72	70	67	67
query56	243	246	214	214
query57	1674	1629	1570	1570
query58	294	270	260	260
query59	2136	2202	2052	2052
query60	302	260	234	234
query61	174	167	159	159
query62	396	347	306	306
query63	194	161	165	161
query64	2854	1048	870	870
query65	3382	3290	3278	3278
query66	1785	432	318	318
query67	20152	20137	20081	20081
query68	2966	999	617	617
query69	402	295	261	261
query70	840	770	820	770
query71	290	237	209	209
query72	2708	2573	2308	2308
query73	511	517	297	297
query74	4632	4540	4330	4330
query75	2317	2294	1912	1912
query76	2297	1002	595	595
query77	340	365	262	262
query78	9809	9961	9175	9175
query79	1023	862	537	537
query80	777	414	361	361
query81	575	323	264	264
query82	566	127	111	111
query83	282	199	178	178
query84	314	113	105	105
query85	953	408	368	368
query86	353	239	231	231
query87	2047	1932	1791	1791
query88	3636	2680	2673	2673
query89	334	292	240	240
query90	1705	165	163	163
query91	160	147	124	124
query92	98	89	83	83
query93	1032	999	582	582
query94	506	327	278	278
query95	574	341	354	341
query96	663	480	232	232
query97	2464	2444	2341	2341
query98	161	150	155	150
query99	761	770	657	657
Total cold run time: 234414 ms
Total hot run time: 150868 ms

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage `` 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 25.15 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit a1c36365694010ca74a1870337f16890751708a5, data reload: false

query1	0.00	0.00	0.01
query2	0.13	0.08	0.08
query3	0.35	0.13	0.12
query4	1.60	0.13	0.14
query5	0.24	0.24	0.25
query6	1.15	0.66	0.65
query7	0.03	0.01	0.01
query8	0.10	0.07	0.06
query9	0.44	0.36	0.36
query10	0.60	0.58	0.61
query11	0.32	0.18	0.17
query12	0.32	0.19	0.18
query13	0.50	0.51	0.52
query14	0.93	0.93	0.93
query15	0.66	0.59	0.60
query16	0.40	0.35	0.36
query17	1.03	1.02	0.99
query18	0.29	0.27	0.28
query19	2.01	1.86	1.88
query20	0.02	0.02	0.01
query21	15.38	0.36	0.31
query22	4.92	0.13	0.14
query23	15.84	0.48	0.29
query24	2.38	0.54	0.38
query25	0.16	0.10	0.10
query26	0.77	0.28	0.21
query27	0.11	0.10	0.10
query28	3.48	0.79	0.39
query29	12.59	4.29	3.34
query30	0.35	0.17	0.16
query31	2.75	0.57	0.31
query32	3.23	0.58	0.47
query33	3.13	3.05	3.09
query34	15.97	4.11	3.43
query35	3.38	3.38	3.39
query36	0.59	0.49	0.49
query37	0.12	0.09	0.09
query38	0.07	0.07	0.06
query39	0.06	0.06	0.05
query40	0.19	0.17	0.16
query41	0.10	0.06	0.05
query42	0.06	0.05	0.06
query43	0.07	0.06	0.06
Total cold run time: 96.82 s
Total hot run time: 25.15 s

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 0.00% (0/7) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 31.25% (15/48) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 66.72% (32083/48083)
Line Coverage 51.56% (339735/658953)
Region Coverage 46.97% (275370/586268)
Branch Coverage 48.70% (130056/267043)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 29.79% (14/47) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.41% (36063/46588)
Line Coverage 62.66% (408482/651916)
Region Coverage 59.17% (345621/584147)
Branch Coverage 60.10% (160099/266390)

@morningman
morningman force-pushed the b2m-t37-lance-be-reader branch from a1c3636 to a19d360 Compare October 26, 2026 03:25
morningman added a commit to morningman/doris that referenced this pull request Oct 26, 2026
…er.h

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary: BE configure fails the build-hygiene gate on this PR
(COMPILE and BE UT): util/threadpool.h now reaches 1088 TUs, one over
its limit of 1087 (baseline 988 +10%). Master reaches 1083. Five of the
new Lance TUs reach the header through exprs/vexpr_context.h ->
storage/segment/column_reader.h -> io/cache/cached_remote_file_reader.h
-> io/cache/block_file_cache.h, an existing chain this PR does not
touch.

One of them did not need to. lance_record_batch_converter.h included
the whole format_v2/table_reader.h for two structs, ColumnDefinition and
GlobalRowIdContext, which are defined in format_v2/column_data.h, and
that header reaches nothing on the chain. The converter header now
includes column_data.h instead, so lance_record_batch_converter.cpp no
longer reaches util/threadpool.h and the reach is 1087, within the
limit. No gate table changes. The other four TUs (lance_reader.cpp,
lance_runtime_filter_helper.cpp and their unit tests) evaluate conjuncts
and runtime filters, so they include vexpr_context.h on purpose;
lance_reader.h still gets table_reader.h directly.

### Release note

None

### Check List (For Author)

- Test: Manual test
    - build-support/check-build-hygiene.sh passes (threadpool.h reach
      1087 of 1087).
    - With the BE UT compile flags, clang++ -fsyntax-only passes on
      lance_record_batch_converter.cpp, on a TU that includes only its
      header, and on the unity batch that holds it.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 31.91% (15/47) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.75% (36002/46302)
Line Coverage 63.11% (406038/643331)
Region Coverage 59.70% (343752/575752)
Branch Coverage 60.80% (159415/262199)

@morningman

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/41617534125

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@morningman

Copy link
Copy Markdown
Contributor Author

[P1] Avoid rebuilding the entire child validity bitmap for every Arrow output block

Reviewed commit: 82260656f278db88cc48849083e19f2ccb0685df

In arrow_parent_nulls.cpp:181-186, the first NULL parent in each requested range causes the helper to allocate and copy/fill a validity bitmap for the entire backing child array, before checking whether the parent actually has any child slots to mask. An ordinary NULL LIST or MAP with equal adjacent offsets therefore pays this cost even though no child bit needs changing.

This also affects existing Arrow IPC reads: the stream reader retains a full Arrow array and repeatedly passes smaller row ranges to the SerDe. The temporary mask is discarded after each call, so a large batch with alternating [1] and NULL lists rebuilds the same full child bitmap for every output block. With N parent rows, M child values, and block size B, this adds Θ(M × ceil(N/B)) bitmap work. For example, N = 2²⁴, M = 2²³, and B = 4096 imply 4 GiB of cumulative temporary bitmap allocations and 8 GiB of zero/fill writes. These are static calculations from the code, not benchmark results. MAP and STRUCT have related repeated-mask paths.

Could we avoid allocation for empty or already-null spans and bound effective masking to the retained source batch or the consumed child interval, for example by caching a mask per source array or correctly rebasing a range-local mask? Skipping empty intervals alone does not address nonempty hidden spans or STRUCT. Please also add allocation-sensitive coverage for repeated small ranges of one large input, including empty NULL intervals and nonempty hidden spans.

This is a performance regression against the merge base; no build, test, or benchmark was run as part of this review.

morningman and others added 17 commits November 5, 2026 15:26
### What problem does this PR solve?

Issue Number: None

Related PR: apache#65730 apache#66608 apache#66805 apache#66841 apache#66999 apache#67005 apache#67011 apache#67110 apache#67268 apache#67289 apache#67325 apache#67650 apache#67921 apache#68028 apache#68335 apache#68375 apache#68615 apache#68690

Problem Summary:

Port the Lance BE read path of branch-4.1/4.2 (as of branch-4.2
cabcf45) to master. Master links liblance_c but has no Lance reader:
file_scan_operator keeps FORMAT_LANCE off FileScannerV2 and V1 has no
Lance path, so every FORMAT_LANCE scan fails today. This change is BE
only. The FE connector plugin and the regression suites follow
separately, so nothing on master plans a Lance scan yet and the BE unit
tests carry the proof.

- Reader: be/src/format_v2/lance/* and format_v2/table/lance_reader.*
  (plain scans, vector and full-text search, Substrait and runtime
  filter pushdown, scalar index segments, COUNT(*) from metadata, LIMIT,
  profiles), copied from branch-4.2 except one local renamed for
  -Wshadow.
- Hooks: FileFormat::LANCE and its exhaustive switches, FileScannerV2
  dispatch, and fetch_table_schema for FORMAT_LANCE. Lance queries use
  FileScannerV2 whatever enable_file_scanner_v2 says, as ADBC and fluss
  do: there is no V1 Lance reader. The second phase of TopN lazy
  materialization reads Lance rows through dataset take_rows.
- Row locations: GlobalRowLoacationV2 gets a version 1 layout with a
  64-bit Lance row id (the version 0 layout is unchanged),
  PRequestBlockDesc.row_id becomes uint64 (varint, wire compatible), and
  the materialization operator and RowIdStorageReader decode and
  validate by version.
- Thrift: the external search request body, TLanceFileDesc 4-6 and
  TExternalSearchRequest 2-4 with the branch-4.1/4.2 ids, in place of the
  comments that reserve them on master (apache#70075); field 6 stays master's
  paimon_options.
  TVectorSearchOptions 6 is branch-4.2's approx_mode (apache#69852), reserved
  in a comment.
- Core: PTypeDesc keeps NULL_TYPE for a NULL literal type, and the
  nullable serde reads an Arrow Null array as NULLs.
- Nine lance_* BE configs, a BE-wide Lance session with an optional
  Foyer data cache and 10 lance_session_* metrics; macOS links IOKit.
- Tests: the Lance BE unit tests and their fixtures, including the
  docker fixture all_types.lance that one of them reads. Under master's
  -ffile-prefix-map the session test locates its fixture from the
  repository root, and the reader tests shrink the data cache to 64 MiB
  under the UT temp dir: the default 100 GiB disk tier is 51200 block
  files that stay open for the rest of the test process.

Not included: the MaterializationOperator::push timer fix (apache#69579), the
Lance index metadata scanner, the FE side, regression suites, and the
4.x changes after cabcf45 (apache#68707, apache#68813, apache#68815).

### Release note

Add nine lance_* BE configs and lance_session_* BE metrics. They take
effect once the FE can plan Lance scans.

### Check List (For Author)

- Test: Unit Test
    - BE UT: the Lance reader, nested Null, runtime filter and session
      suites, plus the row-location protocol and FileScannerV2 cases
- Behavior changed: Yes. PRequestBlockDesc.row_id is uint64, and the
  second phase of TopN lazy materialization rejects row locations of an
  unknown version or size.
- Does this need documentation: No (documented with the FE connector)

Co-authored-by: zhangstar333 <[email protected]>
Co-authored-by: Gabriel <[email protected]>
Co-authored-by: zhaobo wang <[email protected]>
Co-authored-by: jay <[email protected]>
Co-authored-by: FANNG <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…er.h

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary: BE configure fails the build-hygiene gate on this PR
(COMPILE and BE UT): util/threadpool.h now reaches 1088 TUs, one over
its limit of 1087 (baseline 988 +10%). Master reaches 1083. Five of the
new Lance TUs reach the header through exprs/vexpr_context.h ->
storage/segment/column_reader.h -> io/cache/cached_remote_file_reader.h
-> io/cache/block_file_cache.h, an existing chain this PR does not
touch.

One of them did not need to. lance_record_batch_converter.h included
the whole format_v2/table_reader.h for two structs, ColumnDefinition and
GlobalRowIdContext, which are defined in format_v2/column_data.h, and
that header reaches nothing on the chain. The converter header now
includes column_data.h instead, so lance_record_batch_converter.cpp no
longer reaches util/threadpool.h and the reach is 1087, within the
limit. No gate table changes. The other four TUs (lance_reader.cpp,
lance_runtime_filter_helper.cpp and their unit tests) evaluate conjuncts
and runtime filters, so they include vexpr_context.h on purpose;
lance_reader.h still gets table_reader.h directly.

### Release note

None

### Check List (For Author)

- Test: Manual test
    - build-support/check-build-hygiene.sh passes (threadpool.h reach
      1087 of 1087).
    - With the BE UT compile flags, clang++ -fsyntax-only passes on
      lance_record_batch_converter.cpp, on a TU that includes only its
      header, and on the unity batch that holds it.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68649

Problem Summary:

Reproduction: read an Arrow Date32 array holding a day outside
0000-01-01..9999-12-31 into a Doris DATE column, for example 2932897
(10000-01-01) written by PyArrow into a Lance dataset, or the same value
returned by an ADBC driver, an Arrow stream load or the paimon-rust reader.
The row comes back as a non-null `0000-00-00`, also inside ARRAY, STRUCT and
MAP values.

Root cause: the DATE32 branch of the shared
`DataTypeDateV2SerDe::read_column_from_arrow` ignores the bool returned by
`get_date_from_daynr()` (dropped since apache#48944) and appends the
default-constructed value. It also adds the epoch offset in 32 bits, so
INT32_MAX overflows, and it decodes the payload of NULL slots. The DATE64
branch next to it already rejects out-of-range days.

Fix: port the DATE32 range part of the branch-4.1/4.2 fix apache#68649. NULL slots
append the default value without being decoded, the day number is computed
in 64 bits, and a day number <= 0, above DATE_MAX_DAYNR or rejected by
`get_date_from_daynr()` returns `InvalidArgument("Arrow Date32 value is
outside the Doris DATE range: row=..., days=...")` with the 4.x wording.
ARRAY/STRUCT/MAP leaves get the same check through their element SerDes.
`write_column_to_arrow` and the year-zero calendar mapping (the other part of
apache#68649, and master draft apache#67449) are unchanged; the new tests avoid the days
whose meaning that mapping changes.

Before: out-of-range days read as `0000-00-00`. After: the read fails with
the error above; in-range values and NULLs read as before.

The Lance reader ported in apache#69581 (from the branch-4.2 snapshot, which
already contains apache#68649) relies on this SerDe check.

### Release note

Reading Arrow DATE32 values outside 0000-01-01..9999-12-31 (ADBC, Arrow
stream load, paimon-rust, Lance) now fails instead of returning 0000-00-00.

### Check List (For Author)

- Test: Unit Test
    - New: `DataTypeDateTimeV2SerDeTest.RejectsInvalidDate32Values`,
      `LanceTableReaderTypeTest.RejectsDate32OutsideDorisDateRange`,
      `LanceTableReaderTypeTest.ReadsLastDorisDateAndIgnoresNullDate32Payload`.
    - All pass on macOS arm64 (ASAN), together with `DataTypeDateTimeV2SerDeTest.*`,
      `DataTypeSerDeArrowTest.*`, `DataTypeSerDeArrowValidationTest.*`,
      `ArrowColumnToDorisColumnTest.*`, `ParquetExprTest.*`,
      `IcebergV2ReaderTest.*` and the Lance suites. With master's DATE32 branch
      restored, the first two fail.
    - `DataTypeDateTimeV2SerDeTest` aborts in its setup under macOS ASAN on
      master: `load_data_from_csv()` in `be/test/testutil/test_util.cpp`
      dereferences `std::set::end()`. It ran here with that assertion removed
      locally; that fix goes in a separate PR.
- Behavior changed: Yes. An Arrow DATE32 day outside 0000-01-01..9999-12-31
  used to be returned as a non-null 0000-00-00; the scan now fails with an
  InvalidArgument error, as Arrow DATE64 values already did.
- Does this need documentation: No

Co-authored-by: Gabriel <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…decoding

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: write a Lance dataset with a `pa.decimal256(18, 2)` column and
scan it with the Lance reader added in apache#69581. Lance keeps the 256-bit width
on disk, so the scanner returns an Arrow `Decimal256Array`, and the BE crashes
on the first non-empty batch. The same happens for any `decimal256(p, s)` with
p <= 38, also as a list, large_list, fixed_size_list, struct or map leaf. The
existing fixture only has decimal256 columns with precision 39 and 76, which
is why the tests did not catch it.

Root cause: `arrow_field_to_doris_type()` and the FE pick the Doris type from
the precision alone (DECIMAL32 up to 9, DECIMAL64 up to 18, DECIMAL128I up to
38), while `LanceArrowArrayNormalizer` passes the `Decimal256Array` through
unchanged. `DataTypeDecimalSerDe<TYPE_DECIMAL32/64/128I>::read_column_from_arrow`
casts the array with `dynamic_cast<const arrow::DecimalArray*>`, gets null for
a `Decimal256Array`, and dereferences it in `Value()`.

Fix: at schema binding, `LanceArrowArrayNormalizer::create()` records
`decimal128(p, s)` for a Decimal256 field with p <= 38 and marks it as needing
special handling, which parents inherit through the existing child loop.
`normalize_for_doris()` then narrows the array with
`arrow::compute::Cast(..., CastOptions::Safe())` on the query's Arrow memory
pool, the cast master's ADBC normalizer already uses
(`format/arrow/arrow_array_normalizer.cpp`). Nested parents rebuild their type
around the narrowed child through `set_lance_nested_type()`, as for BFloat16
leaves, before compaction. A value outside the declared precision, which is
invalid Arrow data (for example unscaled 10^18 in decimal(18, 2)), fails with
`narrow Lance Decimal256 field '<name>' to decimal128(18, 2) failed: Decimal
value does not fit in precision 18` instead of being truncated. Precision
39..76 keeps Decimal256 and DECIMAL256 as before.

Before: the BE dereferences a null pointer and crashes. After: the values are
read unchanged, and an out-of-precision value fails with an error that names
the field.

branch-4.1 and branch-4.2 have the same normalizer, so the same change applies
there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderTypeTest.NarrowsDecimal256WithDecimal128PrecisionDuringNormalization`,
      `LanceTableReaderTypeTest.KeepsWideDecimal256UnchangedDuringNormalization`,
      `LanceTableReaderTypeTest.ConvertsDecimal256WithDecimal128PrecisionToDorisDecimals`,
      `LanceTableReaderTypeTest.NarrowsDecimal256LeavesInSlicedNestedArrays`,
      `LanceTableReaderTypeTest.RejectsDecimal256ValuesOutsideDeclaredPrecision`,
      `LanceTableReaderTypeTest.ReadsDecimal256WithDecimal128PrecisionFromLanceDataset`
      (writes a decimal256(18, 2) dataset, checks that Lance stores Decimal256 and
      that the reader returns DECIMAL64 values).
    - All pass on macOS arm64 (ASAN) with the Lance suites. With the narrowing
      disabled, the normalization cases fail and the sliced nested case then
      crashes in `DataTypeDecimalSerDe::read_column_from_arrow`, as reported.
- Behavior changed: No (fixes a crash in the reader this PR adds)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: scan a Lance FLOAT or DOUBLE column that holds a negative NaN
(float bits 0xffc00000, double bits 0xfff8000000000000) under a runtime filter
`x >= c`, for example a MIN-only filter from a nested-loop join on `x >= y`.
Doris treats every NaN as the greatest value regardless of its sign
(`GreaterThanEqualsFloat` in `common/compare.h`), so the row satisfies the
filter, but it is missing from the result.

Root cause: `build_range_filter_sql()` translates the filter into the Lance
SQL `(x >= c)`. Lance evaluates it with Arrow/DataFusion comparison kernels
that order floating values by IEEE 754 totalOrder, where a negative NaN sorts
below every finite value, so Lance drops the row before the scanner evaluates
the Doris residual. The residual can only remove rows, not bring them back.

Fix: `build_range_filter_sql()` returns `std::nullopt` for a `>=` filter whose
slot is FLOAT or DOUBLE, so the filter stays residual-only and is reported
under `LanceRuntimeFilterSkippedIds`, the policy `PaimonRustPredicateConverter`
already applies to floating predicates. `<=` filters are still pushed: Lance
then only returns extra negative-NaN rows, which the residual removes. IN
filters are still pushed: a NaN row never matches a finite member, and sets
with NaN members were already rejected. A MIN_MAX filter now pushes only its
`<=` half.

Before: rows holding a negative NaN were dropped under a floating `>=` runtime
filter. After: Lance returns them and the Doris residual decides, so the
result matches a scan without the pushed filter.

branch-4.1 and branch-4.2 carry the identical helper, so the same change
applies there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceRuntimeFilterHelperTest.KeepsFloatingMinFiltersResidualOnly`
      (`>=` skipped, `<=` and IN still pushed, SQL of a MIN_MAX pair),
      `LanceTableReaderFilterTest.PreservesNegativeNaNUnderFloatingMinRuntimeFilter`
      (FLOAT and DOUBLE rows 0.5, 1, 2, +NaN, -NaN under `>= 1`; every row Doris
      keeps must come back from Lance).
    - Both pass on macOS arm64 (ASAN) with the Lance suites; with
      `build_range_filter_sql()` as before this change, both fail.
- Behavior changed: No (the Lance reader is added by this PR; floating `>=`
  runtime filters are no longer pushed into Lance, so such scans may read more
  rows)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…file as in branch-4.x

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68813

Problem Summary:

apache#69581 ports the Lance reader from the branch-4.2 snapshot cabcf45. After
that snapshot, apache#68813 changed two things in the 4.x BE, both ported here.

1. Search filter timing. apache#68813 added `TExternalSearchRequest.prefilter`
   (field 5): unset or false means the TVF search filter refines the top-k
   candidates (postfilter), true means it constrains them before top-k
   (prefilter). The 4.x FE sends it for `vector_search()` and
   `full_text_search()`. Without the field, a master BE always prefiltered
   vector search and never prefiltered full-text search, so a 4.x FE paired
   with it got different rows: on the all_types.lance fixture, top_k 1 with
   search filter `row_id >= 3` returns row 4 with prefilter and no row with
   postfilter.
   - `gensrc/thrift/PlanNodes.thrift`: `5: optional bool prefilter` with the
     4.x comment; the stale `TSearchFilter` comment is replaced.
   - `_configure_vector_search()` and `_configure_full_text_search()` pass
     `request.__isset.prefilter && request.prefilter` to
     `lance_scanner_set_prefilter()`, replacing the comment that
     fragment-scoped nearest queries need prefiltering (master's lance-c pin
     4fafcd64525 no longer does).
   - Compatibility (gensrc/AGENTS.md): field 5 is `optional bool prefilter` on
     the branch-4.2 (a7ae6f4) and branch-4.1 (3f911d0) heads;
     branch-4.0 and branch-3.1 have no `TExternalSearchRequest`. No id is
     reused. On master the field replaces the comment that reserves it
     (apache#70075).

2. Profile layout. The snapshot hangs about 45 counters directly under
   `LanceReader`, looks Lance's scan metrics up in two maps, and creates two
   scalar-index timers from Lance's statistics callback the first time they
   are reported, that is from Lance's execution threads while the scan runs.
   As in apache#68813:
   - The counters are grouped under `LanceTiming`, `LanceIO`, `LanceIndex`,
     `LanceVectorIndex`, `LanceScalarIndex`, `LanceExecutionPlan`,
     `LanceScan`, `LanceSearchPlan` and `LanceDataCache` (label counters,
     `TUnit::NONE`, like `RuntimeFilterInfo`).
   - One immutable table, `LANCE_SCAN_METRICS` in `lance_reader_helper.h`,
     drives both registration and the callback lookup. Every metric is
     registered in `_init_scanner_profile()` before the callback is installed,
     so the callback only updates existing counters and every split has the
     same counter tree.
   - The callback is `noexcept` and swallows exceptions, so nothing unwinds
     through the lance-c ABI.
   - `LanceIndexObjectLoads` (`indices_loaded`), `LanceIndexCacheHits` and
     `LanceIndexCacheMisses` are new; the info strings `LanceTopKPlusOffset`
     and `LanceSubstraitFilterBytes` are dropped.
   - Renames: `LanceScannerReadTime` -> `LanceScannerNextTime`,
     `LanceRuntimeFilterSqlTime` -> `LanceRuntimeFilterSqlTranslationTime`,
     `LanceExecutionIOOps` / `IORequests` / `IOBytesRead` -> `LanceIOReadOps` /
     `LanceIOReadRequests` / `LanceIOReadBytes`,
     `LanceIndexPartitionCacheMissLoads` -> `LanceIndexComponentsLoaded`,
     `LancePrefilter*` -> `RowIdPrefilter*` (`LancePrefilterRowIds` ->
     `RowIdPrefilterIds`), `LanceIVFPartitionsRanked` ->
     `LanceIVFPartitionsConsidered`, `LanceIVFPartitionsSearched` ->
     `LanceIndexPartitionsSearched`, `LanceVectorIndexSegmentsSearched` ->
     `LanceVectorIndexDeltasSearched`, `LanceTaskWaitTime` ->
     `LanceScanTaskWaitTime`, `LanceIVFPartitionRankingTime` ->
     `LanceIVFPartitionSelectionTime`, `LanceScalarIndexSegmentPrepareTime` ->
     `LanceScalarIndexCandidateEvaluationTime`.
   - Master differs from 4.x in one line: the callback asserts with
     `DCHECK_LT` that a found metric has a registered counter, where 4.x
     checks it in the `if`; both come from the same array.
   - Not ported: apache#68813's FE change that makes `AggCounter` print nothing for
     `TUnit.NONE`. It belongs to the FE port; until then master's merged
     profile prints the group labels like the existing `RuntimeFilterInfo`.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderVectorSearchTest.SearchFilterDefaultsToPostfilter`
      (from apache#68813) and `LanceTableReaderFullTextSearchTest.SearchFilterHonorsPrefilterFlag`
      (top_k 1 with filter `row_id <= 2`: no row with postfilter, row 2 with
      prefilter; 4.x has no BE test for the full-text path).
    - Changed as in apache#68813: `AppliesSearchFilterBeforeTopK` becomes
      `AppliesSearchFilterBeforeTopKWhenPrefilterEnabled`;
      `MultiVectorScoresFiltersOffsetsAndIndexedCoverage` and
      `MultiVectorSegmentTopKMatchesIndependentGlobalOracle` set `prefilter`
      for their filtered cases, and the former checks the `RowIdPrefilter*`
      counters and that a flat split exposes `LanceIndexPartitionLoadTime`.
    - All pass on macOS arm64 (ASAN) against master's pinned lance-c,
      with the Lance suites. With vector search always prefiltering and
      full-text search never prefiltering, the two new cases fail.
- Behavior changed: Yes, on the Lance path added by apache#69581. A search filter
  without `prefilter` now postfilters vector search, and full-text search
  honors `prefilter = true`, as on branch-4.1/4.2. Lance profile counters are
  grouped and renamed. Master has no FE that plans Lance scans yet.
- Does this need documentation: No

Co-authored-by: zhangstar333 <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
… planned

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68707, apache#68815

Problem Summary:

apache#69581 ports the Lance reader from the branch-4.2 snapshot cabcf45, where
`_configure_vector_search()` passes the request's `use_index` to
`lance_scanner_set_use_index()`. FE decides per split whether to use an index:
a split it plans with index segments carries their UUIDs, and a split without
them is meant to be searched flat. With `use_index = true` and no segments,
Lance instead searches the split with the first index on the column in
manifest order. That index need not be the one FE chose: when FE finds no
index whose metric matches the query, it plans every split flat, and the
snapshot BE still searched them with the mismatched index. On branch-4.2,
apache#68815 also plans distance-range searches over quantized indexes (IVF_SQ) as
flat fallback splits, because quantized distances can drop rows inside the
range; the snapshot BE searches those with the index and returns too few rows.

Fix: port the split-level rule of apache#68707.
- `lance_scanner_set_use_index(scanner, segment_count > 0)`: a split with
  segments searches exactly those, a split without segments searches flat,
  and `LancePlannedFlatSearchFragmentCount` now matches what Lance does.
- A split that carries index segments while the request sets
  `use_index = false` is rejected with InvalidArgument. FE plans segments only
  when `use_index` allows them, on branch-4.1 (4.1.4 onward) and branch-4.2
  alike, so a 4.x FE never sends this combination.
- Not ported: the rest of apache#68707 (searching historical versions, tags and
  branches; rejecting fragment ids missing from the snapshot; naming the
  snapshot in errors and the profile). That comes with the next 4.x catch-up.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New, from apache#68707 (only master's `RuntimeState(TQueryOptions(), globals)`
      constructor differs): `LanceTableReaderVectorSearchTest.SplitWithoutIndexSegmentsSearchesFlat`,
      `LanceTableReaderVectorSearchTest.RejectsIndexSegmentsWhenUseIndexIsFalse`.
    - Changed as in apache#68707: `MultiVectorScoresFiltersOffsetsAndIndexedCoverage`,
      `MultiVectorTopOnePreservesPrecisionAndBatchIndependence` and
      `MultiVectorCosineMasksUndefinedRows` pass the index segments for their
      indexed cases (new helper `make_indexed_lance_range`) and check
      `LanceIndexPartitionsSearched`; `MultiVectorDefaultMetricIsConsistentAcrossFragments`
      checks that every split is searched flat.
    - All pass on macOS arm64 (ASAN) against master's pinned lance-c,
      with the Lance suites. With `use_index` taken from the request and the
      rejection removed, the two new cases fail.
- Behavior changed: Yes, on the Lance path added by apache#69581: a vector split
  without index segments is searched flat even when `use_index` is true, and a
  split with segments under `use_index = false` is rejected. Master has no FE
  that plans Lance scans yet.
- Does this need documentation: No

Co-authored-by: zy-kkk <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581, apache#68815

Problem Summary:

apache#69581 ports the Lance reader from the branch-4.2 snapshot cabcf45. After
that snapshot, apache#68815 added `TVectorSearchParams.distance_lower_bound` (6) and
`distance_upper_bound` (7) to branch-4.1 and branch-4.2: FLOAT bounds,
inclusive lower and exclusive upper, which the 4.x FE fills for a
single-vector `vector_search()` with distance bounds. apache#68815 kept the
external-search `schema_version` at 1, so a BE cannot tell a bounded request
from an unbounded one by version. A master BE without the fields ignored them
and returned the unbounded top-k rows, for example rows with `_distance`
below the lower bound.

Fix: port the BE side of apache#68815.
- `gensrc/thrift/PlanNodes.thrift`: add `6: optional double
  distance_lower_bound` and `7: optional double distance_upper_bound` to
  `TVectorSearchParams` with the 4.x comment.
- `LanceTableReader::_validate_external_search_request()` rejects bounds on a
  multi-vector query (NotSupported), a bound that is not a finite FLOAT value
  (NaN, infinity, or beyond FLT_MAX), and a lower bound that is not below the
  upper bound at FLOAT precision (InvalidArgument), before the dataset is
  opened.
- `_configure_vector_search()` narrows the bounds to FLOAT and installs them
  with `lance_scanner_set_distance_range()` after `lance_scanner_nearest()`,
  which clears any earlier range. A missing bound is passed as NULL
  (unbounded on that side). Requests without bounds are unchanged.

Before: distance bounds sent by a 4.x FE were silently ignored. After: the
reader returns only rows with lower <= _distance < upper, still capped by
top_k and offset, and rejects invalid bounds.

Compatibility (gensrc/AGENTS.md): fields 6 and 7 of `TVectorSearchParams` are
`optional double distance_lower_bound` and `optional double
distance_upper_bound` on the branch-4.2 (a7ae6f4) and branch-4.1
(3f911d0) heads, the same ids and types as here. branch-4.0 and
branch-3.1 have no `TVectorSearchParams`, and on master the struct is new in
apache#69581 with fields 1-5. No id is reused, and both fields are optional.

`lance_scanner_set_distance_range()` is in master's lance-c pin
(4fafcd64525), so only the BE changes.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New (copied unchanged from apache#68815 except for master's
      `RuntimeState(TQueryOptions(), globals)` constructor):
      `LanceTableReaderVectorSearchTest.ValidatesDistanceRangeBeforeDatasetAccess`,
      `LanceTableReaderVectorSearchTest.AppliesDistanceBoundsAndOffset`,
      `LanceTableReaderVectorSearchTest.DistanceRangeFallbackPreservesExactScores`,
      `LanceTableReaderVectorSearchTest.DistanceRangeCoversIndexedAndAppendedFragments`.
    - Fixtures copied from d306323 (identical on the branch-4.2 head):
      `be/test/format_v2/table/lance/data/distance_range.lance` (11 files),
      `distance_range_dot.lance` (8 files), `distance_range_sq.lance` (8
      files) and their description
      `be/test/format_v2/table/lance/distance_range_fixture.md`.
    - All pass on macOS arm64 (ASAN) against master's pinned lance-c, with the
      Lance suites. `DistanceRangeFallbackPreservesExactScores` needs the
      previous commit: without it the IVF_SQ fallback split is searched with
      the quantized index and loses its only row. With the range not installed,
      the three cases that read rows fail.
- Behavior changed: Yes, on the Lance external-search path added by apache#69581:
  distance bounds are applied instead of ignored, and invalid bounds are
  rejected. Master has no FE that sends Lance search requests yet.
- Does this need documentation: No

Co-authored-by: Gabriel <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ffset

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Scenario (traced in the pinned Lance source, not run end to end): a legacy
(v1) Lance dataset holds six rows of `s STRUCT<b BOOLEAN>` in one writer
batch and is scanned with `batch_size = 3`. The v1 plain decoder returns the
Boolean range [start, end) as a bitmap with bit offset `start % 8`, and its
struct and list readers wrap that child unchanged, so the batch of rows 3..5
is a zero-offset struct over a Boolean child at offset 3. `LIST<BOOLEAN>` gets
values at an offset whenever a writer batch is larger than `batch_size`.

Root cause: `compact_lance_array_if_needed()`
passed a STRUCT through unchanged when its own offset was 0 and every child
had its length, and a LIST, LARGE_LIST, MAP or FIXED_SIZE_LIST when its own
offsets were tight, without looking at the children. With
`enable_arrow_input_validation` (true by default) the Doris SerDes reject an
offset at any depth ("non-zero array offset is not supported"), so a valid
scan failed.

Fix: an array is passed through only when it and every descendant start at
offset 0 and reference exactly their child ranges
(`lance_array_layout_is_compact()`); otherwise the existing
`compact_lance_array()` rebuilds it, which copies every level to offset 0.
Checking only the direct struct children would still miss
`STRUCT<s STRUCT<b BOOLEAN>>`, `STRUCT<l LIST<BOOLEAN>>` and a top-level
`LIST<BOOLEAN>`.

Before: such a batch failed the scan. After: it is compacted and read. Arrays
that are offset-free at every level keep the zero-copy path.

branch-4.1 and branch-4.2 carry the identical normalizer, so the same change
applies there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderTypeTest.CompactsOffsetDescendantsOfTightNestedArrays`
      (STRUCT, LIST, MAP, STRUCT<STRUCT> and STRUCT<LIST> arrays that are
      tight at their own level over a Boolean child at offset 3, read through
      the record batch converter with Arrow input validation on) and
      `KeepsCompactNestedArraysUnchangedDuringNormalization` (the same shapes
      without the offset keep their pointers).
    - Both pass with the Lance suites on macOS arm64 (ASAN). With the previous
      checks, or with only the direct struct children checked, the first test
      fails.
- Behavior changed: No (the Lance reader is added by this PR)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

A Lance dataset can hold a zero-field `STRUCT<>` column; the pinned Lance
decoder supports that layout. `arrow_field_to_doris_type()` mapped it, at any
nesting level, to a nullable `DataTypeStruct` without elements, so the schema
fetch reported it as an ordinary STRUCT. Doris cannot materialize that type:
`ColumnStruct::size()` reads its first child (`columns.at(0)`), and the
nullable wrapper checks the nested size when the column is created, so
reading such a column throws `std::out_of_range`.

Fix: the STRUCT branch returns `NotSupported` for a struct without fields.
`convert_arrow_schema_to_doris()` already maps `NotSupported` to the
`DataTypeNothing` sentinel for unsupported columns, so a zero-field struct at
the top level, or inside a struct, list or map, makes that whole column
unsupported, like the other unsupported Lance types, and the remaining
columns of the dataset are unaffected.

Before: the schema fetch reported `STRUCT<>` as a supported STRUCT column.
After: the column is reported unsupported, and its siblings are scanned as
before.

branch-4.1 and branch-4.2 carry the identical mapping, so the same change
applies there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderSchemaTest.MapsZeroFieldStructsAsUnsupported`
      (a top-level `STRUCT<>` and one nested in a struct, a list and a map
      map to the unsupported sentinel; a non-empty struct still maps to
      STRUCT) and `FetchesZeroFieldStructAsUnsupportedAndScansSiblings` (a
      written dataset with an `id` and a `STRUCT<>` column: the schema fetch
      reports the struct unsupported, and a scan of `id` returns both rows).
    - All 102 Lance tests pass on macOS arm64 (ASAN). Without the fix, both
      new tests fail.
- Behavior changed: No (the Lance reader is added by this PR)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… rows

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Arrow leaves the child values under a NULL list, fixed-size-list, map or
struct row unspecified. A FIXED_SIZE_LIST<DATE32, 1> with validity [1, 0, 1]
may hold the days [0, 2932897, 1]; its rows are [1970-01-01], NULL,
[1970-01-02]. Lance's v2.1 miniblock encoding keeps such child data, and
other Arrow producers (ADBC drivers, Arrow stream load, paimon-rust) may as
well.

Root cause: the array, map and struct SerDes pass the whole child range to
the element SerDe, which skips only the child's own NULLs. The DATE32 range
check added by this PR therefore rejected 2932897 (10000-01-01) and failed
the batch, although no visible value is out of range.

Fix: before reading a child, the nested SerDes hand it a validity bitmap
that also clears the slots hidden by a NULL parent row
(`mask_arrow_children_hidden_by_null_parents()`), so the element SerDe
neither validates nor decodes them; the parent's null map hides them as
before. Only the validity buffer is replaced, and nothing is allocated when
no parent row in the range is NULL. This covers DATE32, DATE64 and any other
check an element SerDe makes.

Before: such a batch failed with "Arrow Date32 value is outside the Doris
DATE range". After: it reads as [1970-01-01], NULL, [1970-01-02].

branch-4.1 and branch-4.2 have the same DATE32 check and nested SerDes.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `ArrowParentNullsTest` (without a NULL parent the child itself is
      returned; only the slots of NULL parents are cleared and the child
      keeps its own NULLs; the field of a sliced struct is addressed with its
      offset) and `LanceTableReaderTypeTest.IgnoresDate32PayloadHiddenByNullParents`
      (FIXED_SIZE_LIST, LIST with a non-empty NULL segment, STRUCT, MAP and
      LIST<STRUCT> over a hidden 10000-01-01).
    - `ArrowParentNullsTest.*`, `DataTypeSerDeArrowTest.*`,
      `DataTypeSerDeArrowValidationTest.*`, `ArrowColumnToDorisColumnTest.*`,
      the Lance and ADBC suites and `IcebergV2ReaderTest.*` on macOS arm64
      (ASAN): 297 pass, 7 ADBC SQLite cases skip (no SQLite driver here).
      With the three SerDe changes reverted, the Lance test fails with
      "Arrow Date32 value is outside the Doris DATE range: row=1,
      days=2932897".
- Behavior changed: No (visible values are unchanged; the check that hidden
  values tripped is added by this PR)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…sks to the query

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

1. The Lance normalizer narrows a Decimal256 leaf of precision <= 38 to
   decimal128 with Arrow's safe cast, and for a nested leaf it cast the whole
   raw child array. The values under a NULL list, fixed-size-list, map or
   struct row, which Arrow leaves unspecified, and for a sliced parent the
   values of the rows outside the slice were precision-checked too. A
   FIXED_SIZE_LIST<decimal256(18,2),1> with validity [1, 0, 1] may hold the
   unscaled children [1, 10^18, 2]; its rows are [0.01], NULL, [0.02], yet
   the hidden 10^18 failed the batch with "Decimal value does not fit in
   precision 18". The SerDe-level masking added for the hidden DATE32 values
   runs only after normalization.

   Fix: before a child goes to a child normalizer that narrows a decimal,
   every child slot that no visible non-NULL parent row references is marked
   NULL (`mask_arrow_child_slots_not_visible()`). Each level masks its own
   children, so the descendants of a hidden slot are hidden too. A visible
   value outside the declared precision is still rejected. A map key cannot
   be NULL, so a Decimal256 map key under a NULL map row is still checked.
   Children that narrow nothing skip the mask.

2. The validity bitmaps of these masks came from Arrow's default pool, which
   the query's memory tracker does not see. They now come from ExecEnv's
   `doris::ArrowMemoryPool`, the pool the Lance converter already uses; unit
   tests that never initialize ExecEnv fall back to the default pool, as the
   converter does.

Before: the batch above failed. After: it reads as [0.01], NULL, [0.02].

branch-4.1 and branch-4.2 do not narrow Lance Decimal256 values and have no
such masks.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderTypeTest.NarrowsDecimal256PayloadsHiddenByNullParents`
      (FIXED_SIZE_LIST, LIST, STRUCT, MAP and LIST<STRUCT> over a hidden
      10^18, and a sliced LIST); `RejectsDecimal256ValuesOutsideDeclaredPrecision`
      gains a nested visible out-of-range value; `ArrowParentNullsTest`
      gains 8 cases (each parent type, a sliced parent, no allocation when
      every slot is visible, offsets outside the child, the mask allocated
      from the Doris Arrow pool, allocation failure).
    - `ArrowParentNullsTest.*`, `DataTypeSerDeArrowTest.*`,
      `DataTypeSerDeArrowValidationTest.*`, `ArrowColumnToDorisColumnTest.*`,
      the Lance and ADBC suites and `IcebergV2ReaderTest.*` on macOS arm64
      (ASAN): 306 pass, 7 ADBC SQLite cases skip (no SQLite driver here).
      Mutations: without the normalizer mask the Lance test fails; with the
      mask allocated from the default pool the two pool tests fail; without
      masking the slots outside a sliced parent the slice test and the Lance
      test fail.
    - clang-tidy (llvm 22) reports only `readability-inconsistent-ifelse-braces`
      on the three `DORIS_CHECK` lines of the new function, the macro's
      expansion that existing `DORIS_CHECK`s report too.
- Behavior changed: No (visible values are unchanged; the narrowing that
  hidden values tripped is added by this PR)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

A Lance nested array whose descendants start at an offset is copied into an
offset-zero array before decoding, and the copy runs `ValidateFull()` first,
for the offset and length checks the builder needs. `ValidateFull()` also
checks every non-NULL value of every descendant over its whole length:
UTF-8 for strings, the precision of decimals, whole days for Date64 and the
day range for Time32 / Time64, also in slots that no visible row reaches.
The slice [1, 4) of a FIXED_SIZE_LIST<STRING, 1> with rows ["z"], ["a"],
NULL, ["c"] may hold the child strings ["z", "a", "\xff", "c"]; its rows are
["a"], NULL, ["c"], yet the hidden "\xff" failed the batch with "Invalid
UTF8 sequence". The previous fixes mask such slots only for the element
SerDes and for the Decimal256 leaves the normalizer narrows, so a hidden
value in any other subtree of a compacted array still failed: a string, a
decimal256 of precision above 38, a Date64 or a time.

Fix: `compact_lance_array()` masks the array before validating it:
`mask_arrow_descendants_not_visible()` marks NULL, at every nesting level,
every slot that no visible row reaches, through the per-level
`mask_arrow_child_slots_not_visible()`. `ValidateFull()` and the copy then
run on the masked array, so the offset and length checks stay and an
invalid visible value still fails. The builders copy no child slot of a NULL
list, map or fixed-size-list row, and the masked fields of a NULL struct
row are read as NULL anyway, so every visible value is unchanged. Map keys
cannot be NULL and stay unmasked. A slot that is NULL already needs no
hiding, so the subtrees the normalizer masked before narrowing get no
second bitmap, and an array with nothing hidden allocates nothing.

Before: the slice above failed to read. After: it reads as ["a"], NULL,
["c"].

branch-4.1 and branch-4.2 do not compact Lance arrays this way.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceTableReaderTypeTest.CompactsArraysWithInvalidPayloadsHiddenByNullParents`
      (FIXED_SIZE_LIST<STRING> with invalid UTF-8, LIST<DECIMAL256(40,2)>
      out of precision and LIST<STRUCT<s, d>> under NULL rows and outside
      the slice), `RejectsVisibleInvalidPayloadsInCompactedArrays` (the same
      values visible), `ArrowParentNullsTest.MasksHiddenSlotsAtEveryNestingLevel`
      and `LeavesVisibleAndAlreadyMaskedArraysUnchanged`.
    - `ArrowParentNullsTest.*`, `DataTypeSerDeArrowTest.*`,
      `DataTypeSerDeArrowValidationTest.*`, `ArrowColumnToDorisColumnTest.*`,
      the Lance and ADBC suites and `IcebergV2ReaderTest.*` on macOS arm64
      (ASAN): 310 pass, 7 ADBC SQLite cases skip. Mutations: without the
      mask before validation the compaction test fails; without recursion
      the nesting test and the compaction test fail; masking already-NULL
      slots again fails the no-second-bitmap test.
    - clang-tidy (llvm 22) reports only `readability-inconsistent-ifelse-braces`
      on the three `DORIS_CHECK` lines of the new function.
- Behavior changed: No (visible values are unchanged)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary: cloud_p0 (UBSAN) reported "load of misaligned address
... requires 4 byte alignment" in arrow/array/array_nested.h, from the
entry_slots lambda of DataTypeMapSerDe::read_column_from_arrow through
mask_arrow_children_hidden_by_null_parents (arrow_parent_nulls.cpp): a
Python UDF returned a MAP with a NULL row over an offsets buffer that is
not 4-byte aligned. The serde reads its own offsets with unaligned_load
for that reason, but the mask added in this PR called
MapArray::value_offset(), an aligned int32_t load.
mask_arrow_child_slots_not_visible did the same for LIST, LARGE_LIST and
MAP parents. All of them now read the offsets with unaligned_load, as the
list and map serdes do.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - BE UT ArrowParentNullsTest.MasksNullRowsBehindMisalignedOffsets
      masks NULL LIST / LARGE_LIST / MAP rows whose offsets buffer starts
      at an odd address; DataTypeMapSerDeTest.ArrowMemNotAligned already
      reads such a MAP with a NULL row. Both flag an aligned load only in
      a UBSAN build; locally (ASAN, macOS arm64) the 152 tests of
      ArrowParentNullsTest, DataTypeMapSerDeTest, DataTypeSerDeArrowTest
      and the Lance suites pass.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…n SQL

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: scan a Lance INT64 column under a runtime filter that mentions
INT64_MIN, for example an ordinary BIGINT join whose build side contains
-9223372036854775808. The IN filter `x IN (-9223372036854775808)` or the
range filter `x <= -9223372036854775808` reaches the Lance scan, and the
query fails reading its first batch instead of returning the matching rows.
A scale-zero DECIMAL128 / DECIMAL256 column fails the same way.

Root cause: `integer_literal_fits_physical_type()` admits every i64 for an
Int64 column, and the scale-zero decimal check admits every value that parses
as an i64, so `to_lance_sql_literal()` emits INT64_MIN. The pinned Lance SQL
parser (lance-datafusion `planner.rs` at 68c12dfd) reads `-N` as the negation
of N and parses N as i64 first. The magnitude 9223372036854775808 does not fit,
so the literal becomes a Float64, and the scalar coercion (`expr.rs`) cannot
turn a Float64 into Int64 or Decimal: the first `lance_scanner_next()` fails
planning. The only existing reader case with INT64_MIN is null-aware, so the
filter was skipped before pushdown and the gap went unnoticed.

Fix: `parse_lance_int64_literal()` parses an integer literal as Lance does and
rejects INT64_MIN. The integer check and the scale-zero decimal check use it,
so a filter that mentions INT64_MIN stays residual-only and is reported under
`LanceRuntimeFilterSkippedIds`; an IN set with that member is rejected as a
unit, as for any member Lance cannot take. Every other i64, INT64_MIN + 1
included, is still pushed.

Before: such a scan failed in Lance planning. After: the filter stays with the
Doris residual and the scan returns the matching rows.

branch-4.1 and branch-4.2 carry the identical helper, so the same change
applies there.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceRuntimeFilterHelperTest.KeepsInt64MinFiltersResidualOnly`
      (IN with an INT64_MIN member, `>=`, `<=` and a scale-zero decimal IN on
      INT64_MIN are skipped; the same filters on INT64_MIN + 1 are pushed with
      the expected SQL),
      `LanceTableReaderFilterTest.KeepsInt64MinRuntimeFiltersOutOfLanceScanner`
      (a Lance dataset holding INT64_MIN read under an INT64 IN, an INT64 `<=`
      and a DECIMAL(38, 0) IN on INT64_MIN; the scan succeeds and reports all
      three as skipped).
    - `run-be-ut.sh --run --filter='Lance*'` on macOS arm64 (ASAN): 106
      tests pass. Restoring `parse_int64()` in either the integer check or
      the scale-zero decimal check makes exactly the two new tests fail,
      the reader case with Lance's "Received literal Float64(...) and
      could not convert" planning error.
- Behavior changed: No (the Lance reader is added by this PR; runtime filters
  that mention INT64_MIN are no longer pushed into Lance, so such scans may
  read more rows)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: set `lance_index_cache_size_bytes = -1` (or
`lance_metadata_cache_size_bytes`) in be.conf and query a Lance table. The
BE starts, and the first Lance scan creates the shared session with an index
(or metadata) cache of 2^64 - 1 bytes, that is with no limit, without any
error. With the data cache enabled, a negative
`lance_data_cache_disk_capacity_bytes` that happens to be a multiple of 4096
(for example -4096) is accepted the same way as a disk tier of almost 2^64
bytes, and a negative `lance_data_cache_read_block_size_bytes` fails with
lance-c's complaint about the wrapped value instead of the setting.

Root cause: the four settings are `int64_t` BE configs without a range check,
and `LanceSessionManager::_initialize()` passes each of them to lance-c with
`static_cast<uint64_t>`.

Fix: `_initialize()` rejects a negative index or metadata cache size, and,
while the data cache is enabled, a negative disk capacity or read block size,
before any cast, with an InvalidArgument status that names the settings and
tells the operator to fix be.conf (or disable the data cache) and restart.
`open_dataset()` retains this status through `call_once` like every other
session initialization failure, so only Lance scans fail and the rest of the
BE keeps serving. The data cache sizes are still ignored while the data cache
is disabled, as `SessionAndDataCacheConfigurationsAreIndependent` requires.
The remaining constraints (a non-zero multiple of 4096, room for the memory
tier block and two disk blocks) stay with lance-c, which reports them on the
same path.

Before: a negative cache size silently meant an unlimited cache, or a
lance-c error about a huge value. After: the first Lance scan fails with
"... must not be negative. Fix be.conf and restart this BE ...".

branch-4.1 and branch-4.2 pass the same settings through the same casts.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `LanceSessionManagerTest.RejectsNegativeCacheSizesBeforeCreatingTheSession`
      (a negative index cache size and a negative metadata cache size with the
      data cache disabled; a negative disk capacity and a negative read block
      size with the data cache enabled; each fails with INVALID_ARGUMENT
      "must not be negative" and opens no dataset).
    - `run-be-ut.sh --run --filter='Lance*'` on macOS arm64 (ASAN): 107 tests
      pass. Disabling both checks makes the new test, and only it, fail: the
      negative index and metadata sizes open the dataset, and with the data
      cache enabled lance-c reports the wrapped value instead
      (`disk_capacity_bytes=18446744073709551615 must be a multiple of 4096 ...`).
    - clang-format 16, clang-tidy on the changed lines and
      `check-build-hygiene.sh` pass.
- Behavior changed: No (the Lance reader is added by this PR; a negative
  Lance cache size now fails Lance scans instead of meaning no limit)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…output block

### What problem does this PR solve?

Issue Number: None

Related PR: apache#69581

Problem Summary:

Reproduction: load one large Arrow IPC batch through Arrow stream load with
a nested column that has NULL rows. For example, take 2^24 LIST<INT> rows
that alternate between [1] and NULL (2^23 child values), read in blocks of
4096 rows. Every block allocates a 1 MiB validity bitmap, zero-fills it and
copies the child's validity into it, so the batch allocates 4 GiB and writes
8 GiB from the query's Arrow pool. A STRUCT field costs N^2/B bits per batch
the same way. So does a NULL list row whose producer left values in its
span.

Root cause: `mask_arrow_children_hidden_by_null_parents()` was added earlier
in this PR so that element SerDes never decode child values hidden by a NULL
list, fixed-size-list, map or struct row. At the first NULL parent row of the
requested range, it allocated a bitmap covering the whole child and copied
the child's validity, before it looked at that row's child span. A NULL row
that owns no slots, as Arrow's list and map builders write it, or only NULL
slots, hides nothing but paid the same. `ArrowStreamReader` keeps each IPC
batch and hands the SerDes one row range per output block, and the mask is
thrown away after each call, so the bitmap was rebuilt for every block. The
map SerDe builds one for the keys and one for the items, and the struct
SerDe one per field. Since the bitmaps come from ExecEnv's accounted Arrow
pool, these temporary allocations also count against the query's memory
limit.

Fix:

1. `mask_arrow_children_hidden_by_null_parents()` reads a NULL row's child
   span first and skips the row when the span is empty or has no set
   validity bit in [offset + begin, offset + end). A child without a validity
   buffer counts as all valid. The bitmap is allocated at the first row that
   really hides a valid value. The check, `has_valid_slot()`, is now shared
   with `mask_unreferenced_slots()`, the slot mask of the Lance path. A NULL
   row that hides a valid value is still masked, and its bitmap still comes
   from ExecEnv's Arrow pool. Offsets are still read through the SerDes'
   unaligned loads, still after their offset validation, and an Arrow Null
   child is still returned as it is.
2. `ArrowStreamReader::_prepare_arrays()` masks each column of a retained
   batch once, after the existing normalization and cast. It uses
   `mask_arrow_descendants_not_visible()` with the reader's accounted pool.
   The per-block SerDe calls then find nothing left to hide and allocate
   nothing. If this mask fails, the error names the column the way the
   per-block errors do. It rejects only offsets that reach outside the child
   or run backwards at a NULL row. `enable_arrow_input_validation` (on by
   default) rejected those batches as well, in the block that read them.

What remains: a MapArray cannot hold NULL keys, so the per-batch mask leaves
the keys alone. A NULL map row that still owns entries therefore costs each
block a key bitmap. Arrow's map builder writes NULL map rows without
entries. The other Arrow readers (ADBC, remote Doris, Lance, the Arrow block
convertor, Python UDTF) read each batch with one SerDe call, so they pay for
the mask at most once per batch. paimon-rust reads row ranges of batches
about the size of a block.

Before: every block of a large nested batch with a NULL row allocated and
filled a bitmap the size of the whole child. After: the blocks allocate
nothing from the SerDes' pool, and each batch is masked at most once per
nested level. In the example above nothing is allocated at all, because
the NULL rows own no values.

Also covered: the NULL-parent case of the DATETIMEV2 / TIMESTAMPTZ range
checks from apache#69697. That PR merged before this one and its tests use visible
values only.

master, branch-4.1 and branch-4.2 have no parent-null masks (this PR adds
them), so nothing needs porting.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - New: `ArrowParentNullsTest.AllocatesNothingForNullRowsThatHideNothing`
      covers LIST rows that alternate [1] and NULL, read two rows per call; a
      NULL MAP row without entries (keys and items); and LIST and
      FIXED_SIZE_LIST NULL rows over NULL slots. Each returns the child
      itself and allocates nothing from the Doris Arrow pool. A NULL row over
      valid values next to them is still masked, with one bitmap.
    - New: `LoadArrowPipeTest.Null{List,FixedSizeList,Map,Struct}BatchReadsInBlocksWithoutPerBlockMasks`
      read one IPC batch of 64 rows four rows per block through
      `ArrowStreamReader`, with every fourth row NULL. No block allocates
      from the pool the SerDes mask with, and every row reads as one SerDe
      call over the whole batch reads it.
    - New: `DataTypeDateTimeV2SerDeTest.IgnoresArrowTimestampsHiddenByNullParents`
      hides 65537-01-01 under a NULL LIST, FIXED_SIZE_LIST, MAP value and
      STRUCT row, as DATETIMEV2 and as TIMESTAMPTZ. It reads as a NULL
      element, while the same value in a visible row fails with the range
      error.
    - Done: `run-be-ut.sh --run --filter=...` (ASAN, macOS arm64) over 24 suites:
      `ArrowParentNullsTest`, `LoadArrowPipeTest`, the DATETIMEV2 / TIMESTAMPTZ,
      Arrow, Arrow validation, array, map and struct SerDe suites,
      `ArrowColumnToDorisColumnTest`, `ArrowBlockConvertorTest`, `AdbcReaderTest`,
      `RemoteDorisV2ReaderTest`, `PaimonRustTableReaderTest` and `Lance*`: 328 of
      328 pass. clang-format 16 and `check-build-hygiene.sh` pass.
    - Mutations: allocating at the first NULL row of each call again (B1 off)
      fails the new `ArrowParentNullsTest` case and the four new
      `LoadArrowPipeTest` cases; dropping the per-batch mask's result (B2 off)
      fails the LIST and STRUCT `LoadArrowPipeTest` cases (the reader casts a
      FIXED_SIZE_LIST to a compacted LIST, and a NULL map row owns no entries,
      so B1 covers those two); returning the child unmasked fails the new
      timestamp case, four `ArrowParentNullsTest` cases and
      `LanceTableReaderTypeTest.IgnoresDate32PayloadHiddenByNullParents`.
- Behavior changed: No (the values read are unchanged; only the
  allocations change)
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@morningman

Copy link
Copy Markdown
Contributor Author

Re: [P1] Avoid rebuilding the entire child validity bitmap for every Arrow output block (#69581 (comment))

Addressed in 42cf4e3e5d1, on top of the rebase onto master 315c2e00331.

  • mask_arrow_children_hidden_by_null_parents() now reads a NULL row's child span before it allocates. A span that is empty, or whose slots are NULL already, hides nothing, so such a row allocates no bitmap, and the child itself is returned when no NULL row in the range hides a valid value. This is what mask_unreferenced_slots() on the Lance path already did; the two now share has_valid_slot(). The alternating [1] / NULL lists in your example cost no bitmap at all.
  • ArrowStreamReader::_prepare_arrays() masks each retained IPC batch once with mask_arrow_descendants_not_visible(), using the reader's accounted pool, before get_block() hands its row ranges to the SerDes. A large LIST, FIXED_SIZE_LIST or STRUCT column whose NULL rows hide valid values is therefore masked once per batch, and the per-block SerDe calls find nothing left to hide.
  • What remains per call: map keys under a NULL map row that still owns entries, because a MapArray cannot hold NULL keys (Arrow's map builder writes NULL map rows without entries). paimon-rust also reads row ranges of one batch, but its batches are about the size of a block.

Tests: ArrowParentNullsTest.AllocatesNothingForNullRowsThatHideNothing (empty and already-NULL spans allocate nothing from the Doris Arrow pool; a row that hides a valid value is still masked) and LoadArrowPipeTest.Null{List,FixedSizeList,Map,Struct}BatchReadsInBlocksWithoutPerBlockMasks (one 64-row IPC batch read four rows per block: no block allocates from the masking pool, and every row reads as a whole-batch read does). Turning either change off fails its cases: allocating at the first NULL row of each call again fails all five, and dropping the per-batch mask fails the LIST and STRUCT block cases. DataTypeDateTimeV2SerDeTest.IgnoresArrowTimestampsHiddenByNullParents adds the NULL-parent case of the #69697 range checks: an out-of-range DATETIMEV2 / TIMESTAMPTZ under a NULL LIST, FIXED_SIZE_LIST, MAP value or STRUCT row reads as NULL, while the same value in a visible row still fails.

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28866 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 42cf4e3e5d1425df58db710758608342811620e8, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17581	5184	5143	5143
q2	2161	301	256	256
q3	10239	1404	835	835
q4	4676	453	338	338
q5	7451	815	527	527
q6	182	185	154	154
q7	751	805	600	600
q8	9270	1364	1067	1067
q9	5710	4223	4207	4207
q10	6846	1312	1038	1038
q11	443	253	237	237
q12	638	400	276	276
q13	18120	3737	3052	3052
q14	281	274	247	247
q15	q16	724	721	668	668
q17	1200	761	583	583
q18	6996	6192	6103	6103
q19	1104	962	592	592
q20	377	342	232	232
q21	6036	2440	2426	2426
q22	393	350	285	285
Total cold run time: 101179 ms
Total hot run time: 28866 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5967	5991	6168	5991
q2	748	630	578	578
q3	5677	5882	5298	5298
q4	2035	2146	1546	1546
q5	5745	5789	5621	5621
q6	239	184	141	141
q7	2239	2008	1827	1827
q8	2998	2671	2771	2671
q9	8041	7739	7514	7514
q10	3648	3660	3118	3118
q11	578	412	385	385
q12	664	666	524	524
q13	3281	3629	3080	3080
q14	291	295	285	285
q15	q16	670	681	617	617
q17	7722	7170	6940	6940
q18	13014	12221	12901	12221
q19	897	770	767	767
q20	2142	2131	1912	1912
q21	5584	4592	4733	4592
q22	482	440	396	396
Total cold run time: 72662 ms
Total hot run time: 66024 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 154247 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 42cf4e3e5d1425df58db710758608342811620e8, data reload: false

query5	4322	592	459	459
query6	436	206	197	197
query7	4801	459	251	251
query8	333	171	159	159
query9	8791	3220	3192	3192
query10	484	327	265	265
query11	5886	3868	3572	3572
query12	149	105	90	90
query13	1268	449	347	347
query14	6157	3941	3642	3642
query14_1	3421	3444	3449	3444
query15	211	203	188	188
query16	955	430	418	418
query17	860	654	535	535
query18	2421	423	309	309
query19	203	163	140	140
query20	87	84	85	84
query21	209	125	104	104
query22	13154	12994	12864	12864
query23	13446	12904	12606	12606
query23_1	12527	12916	12482	12482
query24	7380	821	476	476
query24_1	487	486	501	486
query25	544	426	373	373
query26	1273	255	140	140
query27	2784	441	283	283
query28	4626	2052	2024	2024
query29	1577	601	466	466
query30	301	221	180	180
query31	1015	941	820	820
query32	157	119	96	96
query33	533	309	248	248
query34	930	863	501	501
query35	749	756	651	651
query36	814	814	744	744
query37	125	103	86	86
query38	1805	1756	1682	1682
query39	801	785	745	745
query39_1	737	739	739	739
query40	224	119	98	98
query41	73	71	71	71
query42	90	85	83	83
query43	348	358	311	311
query44	1315	711	716	711
query45	223	178	161	161
query46	824	953	568	568
query47	2933	2944	2802	2802
query48	290	314	222	222
query49	592	394	293	293
query50	666	288	206	206
query51	10662	10738	10457	10457
query52	78	81	72	72
query53	181	211	148	148
query54	251	208	193	193
query55	70	67	69	67
query56	239	212	222	212
query57	1652	1650	1561	1561
query58	285	265	262	262
query59	2075	2143	2007	2007
query60	286	243	233	233
query61	152	148	165	148
query62	389	337	285	285
query63	188	155	158	155
query64	2736	937	827	827
query65	3363	3278	3269	3269
query66	1770	418	302	302
query67	22793	22698	22554	22554
query68	3268	999	609	609
query69	414	291	249	249
query70	822	810	781	781
query71	304	235	209	209
query72	2841	2622	2287	2287
query73	538	523	294	294
query74	4608	4520	4331	4331
query75	2308	2280	1924	1924
query76	2405	1010	599	599
query77	371	368	268	268
query78	9819	9935	9188	9188
query79	994	863	527	527
query80	1204	434	332	332
query81	581	318	266	266
query82	568	131	102	102
query83	305	196	177	177
query84	310	130	100	100
query85	856	411	364	364
query86	389	241	233	233
query87	1984	1955	1801	1801
query88	3845	2785	2766	2766
query89	334	277	244	244
query90	1922	170	156	156
query91	166	146	128	128
query92	91	92	91	91
query93	1106	1014	582	582
query94	656	315	260	260
query95	582	395	300	300
query96	672	485	242	242
query97	2425	2418	2328	2328
query98	170	151	154	151
query99	780	745	640	640
Total cold run time: 238726 ms
Total hot run time: 154247 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.51 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 42cf4e3e5d1425df58db710758608342811620e8, data reload: false

query1	0.01	0.00	0.01
query2	0.09	0.04	0.05
query3	0.27	0.09	0.10
query4	1.60	0.10	0.11
query5	0.23	0.24	0.25
query6	1.18	0.68	0.67
query7	0.04	0.01	0.00
query8	0.05	0.04	0.04
query9	0.40	0.33	0.36
query10	0.60	0.63	0.60
query11	0.20	0.15	0.14
query12	0.18	0.15	0.15
query13	0.50	0.51	0.50
query14	1.04	1.02	1.00
query15	0.66	0.61	0.63
query16	0.34	0.36	0.35
query17	1.18	1.19	1.11
query18	0.20	0.19	0.18
query19	2.00	2.02	1.99
query20	0.02	0.01	0.01
query21	15.53	0.20	0.13
query22	4.86	0.05	0.06
query23	16.26	0.34	0.13
query24	2.95	0.65	0.43
query25	0.11	0.06	0.04
query26	1.30	0.21	0.16
query27	0.05	0.04	0.05
query28	3.21	0.77	0.38
query29	12.49	4.12	3.23
query30	0.29	0.12	0.10
query31	2.77	0.54	0.33
query32	3.22	0.60	0.50
query33	3.15	3.24	3.22
query34	15.55	3.99	3.39
query35	3.34	3.35	3.32
query36	0.56	0.62	0.53
query37	0.09	0.07	0.07
query38	0.06	0.05	0.04
query39	0.04	0.03	0.03
query40	0.18	0.16	0.15
query41	0.08	0.03	0.03
query42	0.04	0.03	0.03
query43	0.05	0.04	0.03
Total cold run time: 96.97 s
Total hot run time: 24.51 s

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 31.91% (15/47) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.64% (35993/46358)
Line Coverage 63.05% (406206/644251)
Region Coverage 59.53% (343329/576687)
Branch Coverage 60.71% (159520/262756)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants