Skip to main content

lance_index/scalar/
btree.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use lance_core::utils::row_addr_remap::RowAddrRemap;
5use std::{
6    any::Any,
7    cmp::Ordering,
8    collections::{HashMap, HashSet},
9    fmt::{Debug, Display},
10    ops::Bound,
11    sync::Arc,
12};
13
14use super::{
15    AnyQuery, BuiltinIndexType, IndexFile, IndexReader, IndexStore, IndexWriter, MetricsCollector,
16    OldIndexDataFilter, SargableQuery, ScalarIndex, ScalarIndexParams, SearchResult,
17    compute_next_prefix,
18};
19use crate::cache_pb::{BTreeIndexHeader, RangeToFile};
20use crate::{Index, IndexType};
21use crate::{metrics::NoOpMetricsCollector, scalar::registry::TrainingCriteria};
22use crate::{pbold, scalar::btree::flat::FlatIndex};
23use crate::{
24    progress::{IndexBuildProgress, noop_progress},
25    scalar::{
26        CreatedIndex, RowIdRemapper, UpdateCriteria,
27        expression::{SargableQueryParser, ScalarQueryParser},
28        registry::{
29            BasicTrainer, ScalarIndexLoad, ScalarIndexPlugin, TrainingOrdering, TrainingRequest,
30            VALUE_COLUMN_NAME, single_flight_open,
31        },
32    },
33};
34use arrow_arith::numeric::add;
35use arrow_array::{
36    Array, ArrayAccessor, ArrowNativeTypeOp, PrimitiveArray, RecordBatch, UInt32Array,
37    cast::AsArray,
38    new_empty_array,
39    types::{
40        ArrowPrimitiveType, Decimal128Type, Decimal256Type, Float16Type, Float32Type, Float64Type,
41        Int8Type, Int16Type, Int32Type, Int64Type, UInt8Type, UInt16Type, UInt32Type, UInt64Type,
42    },
43};
44use arrow_ord::ord::make_comparator;
45use arrow_schema::{DataType, Field, IntervalUnit, Schema, SortOptions};
46use async_trait::async_trait;
47use datafusion::physical_plan::{
48    ExecutionPlan, SendableRecordBatchStream,
49    sorts::sort_preserving_merge::SortPreservingMergeExec, stream::RecordBatchStreamAdapter,
50    union::UnionExec,
51};
52use datafusion_common::{DFSchema, DataFusionError, ScalarValue};
53use datafusion_expr::execution_props::ExecutionProps;
54use datafusion_physical_expr::{
55    PhysicalExpr, PhysicalSortExpr, create_physical_expr, expressions::Column,
56};
57use futures::{
58    FutureExt, Stream, StreamExt, TryFutureExt, TryStreamExt,
59    future::BoxFuture,
60    stream::{self},
61};
62use lance_core::deepsize::DeepSizeOf;
63use lance_core::{
64    Error, ROW_ID, Result,
65    cache::{
66        CacheCodec, CacheCodecImpl, CacheEntryReader, CacheEntryWriter, CacheKey, CacheKeySchema,
67        KeyBuilder, LanceCache, WeakLanceCache,
68    },
69    error::LanceOptionExt,
70    utils::{
71        tokio::get_num_compute_intensive_cpus,
72        tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS},
73    },
74};
75use lance_datafusion::{
76    chunker::chunk_concat_stream,
77    exec::{LanceExecutionOptions, OneShotExec, execute_plan},
78};
79use lance_select::{NullableRowAddrSet, RowSetOps};
80use log::{debug, warn};
81use object_store::Error as ObjectStoreError;
82use rangemap::RangeInclusiveMap;
83use roaring::RoaringBitmap;
84use serde::{Deserialize, Serialize, Serializer};
85use tracing::{info, instrument};
86
87mod flat;
88
89pub const BTREE_LOOKUP_NAME: &str = "page_lookup.lance";
90const BTREE_PAGES_NAME: &str = "page_data.lance";
91pub const DEFAULT_BTREE_BATCH_SIZE: u64 = 4096;
92const BATCH_SIZE_META_KEY: &str = "batch_size";
93const DEFAULT_RANGE_PARTITIONED: bool = false;
94const RANGE_PARTITIONED_META_KEY: &str = "range_partitioned";
95const PAGE_NUM_PER_RANGE_PARTITION_META_KEY: &str = "page_num_per_range_partition";
96const BTREE_INDEX_VERSION: u32 = 0;
97pub(crate) const BTREE_VALUES_COLUMN: &str = "values";
98pub(crate) const BTREE_IDS_COLUMN: &str = "ids";
99
100/// Wraps a ScalarValue and implements Ord (ScalarValue only implements PartialOrd)
101#[derive(Clone, Debug)]
102pub struct OrderableScalarValue(pub ScalarValue);
103
104impl DeepSizeOf for OrderableScalarValue {
105    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
106        // deepsize and size both factor in the size of the ScalarValue
107        self.0.size() - std::mem::size_of::<ScalarValue>()
108    }
109}
110
111impl Display for OrderableScalarValue {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        std::fmt::Display::fmt(&self.0, f)
114    }
115}
116
117impl PartialEq for OrderableScalarValue {
118    fn eq(&self, other: &Self) -> bool {
119        self.0.eq(&other.0)
120    }
121}
122
123impl Eq for OrderableScalarValue {}
124
125impl PartialOrd for OrderableScalarValue {
126    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
127        Some(self.cmp(other))
128    }
129}
130
131// manual implementation of `Ord` that panics when asked to compare scalars of different type
132// and always puts nulls before non-nulls (this is consistent with Option<T>'s implementation
133// of Ord)
134//
135// TODO: Consider upstreaming this
136impl Ord for OrderableScalarValue {
137    fn cmp(&self, other: &Self) -> Ordering {
138        use ScalarValue::*;
139        // This purposely doesn't have a catch-all "(_, _)" so that
140        // any newly added enum variant will require editing this list
141        // or else face a compile error
142        match (&self.0, &other.0) {
143            (Decimal32(v1, p1, s1), Decimal32(v2, p2, s2)) => {
144                if p1.eq(p2) && s1.eq(s2) {
145                    v1.cmp(v2)
146                } else {
147                    // Two decimal values can only be compared if they have the same precision and scale.
148                    panic!("Attempt to compare decimals with unequal precision / scale")
149                }
150            }
151            (Decimal32(v1, _, _), Null) => {
152                if v1.is_none() {
153                    Ordering::Equal
154                } else {
155                    Ordering::Greater
156                }
157            }
158            (Decimal32(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
159            (Decimal64(v1, p1, s1), Decimal64(v2, p2, s2)) => {
160                if p1.eq(p2) && s1.eq(s2) {
161                    v1.cmp(v2)
162                } else {
163                    // Two decimal values can only be compared if they have the same precision and scale.
164                    panic!("Attempt to compare decimals with unequal precision / scale")
165                }
166            }
167            (Decimal64(v1, _, _), Null) => {
168                if v1.is_none() {
169                    Ordering::Equal
170                } else {
171                    Ordering::Greater
172                }
173            }
174            (Decimal64(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
175            (Decimal128(v1, p1, s1), Decimal128(v2, p2, s2)) => {
176                if p1.eq(p2) && s1.eq(s2) {
177                    v1.cmp(v2)
178                } else {
179                    // Two decimal values can only be compared if they have the same precision and scale.
180                    panic!("Attempt to compare decimals with unequal precision / scale")
181                }
182            }
183            (Decimal128(v1, _, _), Null) => {
184                if v1.is_none() {
185                    Ordering::Equal
186                } else {
187                    Ordering::Greater
188                }
189            }
190            (Decimal128(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
191            (Decimal256(v1, p1, s1), Decimal256(v2, p2, s2)) => {
192                if p1.eq(p2) && s1.eq(s2) {
193                    v1.cmp(v2)
194                } else {
195                    // Two decimal values can only be compared if they have the same precision and scale.
196                    panic!("Attempt to compare decimals with unequal precision / scale")
197                }
198            }
199            (Decimal256(v1, _, _), Null) => {
200                if v1.is_none() {
201                    Ordering::Equal
202                } else {
203                    Ordering::Greater
204                }
205            }
206            (Decimal256(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
207
208            (Boolean(v1), Boolean(v2)) => v1.cmp(v2),
209            (Boolean(v1), Null) => {
210                if v1.is_none() {
211                    Ordering::Equal
212                } else {
213                    Ordering::Greater
214                }
215            }
216            (Boolean(_), _) => panic!("Attempt to compare boolean with non-boolean"),
217            (Float32(v1), Float32(v2)) => match (v1, v2) {
218                (Some(f1), Some(f2)) => f1.total_cmp(f2),
219                (None, Some(_)) => Ordering::Less,
220                (Some(_), None) => Ordering::Greater,
221                (None, None) => Ordering::Equal,
222            },
223            (Float32(v1), Null) => {
224                if v1.is_none() {
225                    Ordering::Equal
226                } else {
227                    Ordering::Greater
228                }
229            }
230            (Float32(_), _) => panic!("Attempt to compare f32 with non-f32"),
231            (Float64(v1), Float64(v2)) => match (v1, v2) {
232                (Some(f1), Some(f2)) => f1.total_cmp(f2),
233                (None, Some(_)) => Ordering::Less,
234                (Some(_), None) => Ordering::Greater,
235                (None, None) => Ordering::Equal,
236            },
237            (Float64(v1), Null) => {
238                if v1.is_none() {
239                    Ordering::Equal
240                } else {
241                    Ordering::Greater
242                }
243            }
244            (Float64(_), _) => panic!("Attempt to compare f64 with non-f64"),
245            (Float16(v1), Float16(v2)) => match (v1, v2) {
246                (Some(f1), Some(f2)) => f1.total_cmp(f2),
247                (None, Some(_)) => Ordering::Less,
248                (Some(_), None) => Ordering::Greater,
249                (None, None) => Ordering::Equal,
250            },
251            (Float16(v1), Null) => {
252                if v1.is_none() {
253                    Ordering::Equal
254                } else {
255                    Ordering::Greater
256                }
257            }
258            (Float16(_), _) => panic!("Attempt to compare f16 with non-f16"),
259            (Int8(v1), Int8(v2)) => v1.cmp(v2),
260            (Int8(v1), Null) => {
261                if v1.is_none() {
262                    Ordering::Equal
263                } else {
264                    Ordering::Greater
265                }
266            }
267            (Int8(_), _) => panic!("Attempt to compare Int8 with non-Int8"),
268            (Int16(v1), Int16(v2)) => v1.cmp(v2),
269            (Int16(v1), Null) => {
270                if v1.is_none() {
271                    Ordering::Equal
272                } else {
273                    Ordering::Greater
274                }
275            }
276            (Int16(_), _) => panic!("Attempt to compare Int16 with non-Int16"),
277            (Int32(v1), Int32(v2)) => v1.cmp(v2),
278            (Int32(v1), Null) => {
279                if v1.is_none() {
280                    Ordering::Equal
281                } else {
282                    Ordering::Greater
283                }
284            }
285            (Int32(_), _) => panic!("Attempt to compare Int32 with non-Int32"),
286            (Int64(v1), Int64(v2)) => v1.cmp(v2),
287            (Int64(v1), Null) => {
288                if v1.is_none() {
289                    Ordering::Equal
290                } else {
291                    Ordering::Greater
292                }
293            }
294            (Int64(_), _) => panic!("Attempt to compare Int64 with non-Int64"),
295            (UInt8(v1), UInt8(v2)) => v1.cmp(v2),
296            (UInt8(v1), Null) => {
297                if v1.is_none() {
298                    Ordering::Equal
299                } else {
300                    Ordering::Greater
301                }
302            }
303            (UInt8(_), _) => panic!("Attempt to compare UInt8 with non-UInt8"),
304            (UInt16(v1), UInt16(v2)) => v1.cmp(v2),
305            (UInt16(v1), Null) => {
306                if v1.is_none() {
307                    Ordering::Equal
308                } else {
309                    Ordering::Greater
310                }
311            }
312            (UInt16(_), _) => panic!("Attempt to compare UInt16 with non-UInt16"),
313            (UInt32(v1), UInt32(v2)) => v1.cmp(v2),
314            (UInt32(v1), Null) => {
315                if v1.is_none() {
316                    Ordering::Equal
317                } else {
318                    Ordering::Greater
319                }
320            }
321            (UInt32(_), _) => panic!("Attempt to compare UInt32 with non-UInt32"),
322            (UInt64(v1), UInt64(v2)) => v1.cmp(v2),
323            (UInt64(v1), Null) => {
324                if v1.is_none() {
325                    Ordering::Equal
326                } else {
327                    Ordering::Greater
328                }
329            }
330            (UInt64(_), _) => panic!("Attempt to compare UInt64 with non-UInt64"),
331            (Utf8(v1) | Utf8View(v1) | LargeUtf8(v1), Utf8(v2) | Utf8View(v2) | LargeUtf8(v2)) => {
332                v1.cmp(v2)
333            }
334            (Utf8(v1) | Utf8View(v1) | LargeUtf8(v1), Null) => {
335                if v1.is_none() {
336                    Ordering::Equal
337                } else {
338                    Ordering::Greater
339                }
340            }
341            (Utf8(_) | Utf8View(_) | LargeUtf8(_), _) => {
342                panic!("Attempt to compare Utf8 with non-Utf8")
343            }
344            (
345                Binary(v1) | LargeBinary(v1) | BinaryView(v1),
346                Binary(v2) | LargeBinary(v2) | BinaryView(v2),
347            ) => v1.cmp(v2),
348            (Binary(v1) | LargeBinary(v1) | BinaryView(v1), Null) => {
349                if v1.is_none() {
350                    Ordering::Equal
351                } else {
352                    Ordering::Greater
353                }
354            }
355            (Binary(_) | LargeBinary(_) | BinaryView(_), _) => {
356                panic!("Attempt to compare Binary with non-Binary")
357            }
358            (FixedSizeBinary(_, v1), FixedSizeBinary(_, v2)) => v1.cmp(v2),
359            (FixedSizeBinary(_, v1), Null) => {
360                if v1.is_none() {
361                    Ordering::Equal
362                } else {
363                    Ordering::Greater
364                }
365            }
366            (FixedSizeBinary(_, _), _) => {
367                panic!("Attempt to compare FixedSizeBinary with non-FixedSizeBinary")
368            }
369            (FixedSizeList(left), FixedSizeList(right)) => {
370                if left.eq(right) {
371                    todo!()
372                } else {
373                    panic!(
374                        "Attempt to compare fixed size list elements with different widths/fields"
375                    )
376                }
377            }
378            (FixedSizeList(left), Null) => {
379                if left.is_null(0) {
380                    Ordering::Equal
381                } else {
382                    Ordering::Greater
383                }
384            }
385            (FixedSizeList(_), _) => {
386                panic!("Attempt to compare FixedSizeList with non-FixedSizeList")
387            }
388            (List(_), List(_)) => todo!(),
389            (List(left), Null) => {
390                if left.is_null(0) {
391                    Ordering::Equal
392                } else {
393                    Ordering::Greater
394                }
395            }
396            (List(_), _) => {
397                panic!("Attempt to compare List with non-List")
398            }
399            (LargeList(_), _) => todo!(),
400            (ListView(_), _) => todo!(),
401            (LargeListView(_), _) => todo!(),
402            (Map(_), Map(_)) => todo!(),
403            (Map(left), Null) => {
404                if left.is_null(0) {
405                    Ordering::Equal
406                } else {
407                    Ordering::Greater
408                }
409            }
410            (Map(_), _) => {
411                panic!("Attempt to compare Map with non-Map")
412            }
413            (Date32(v1), Date32(v2)) => v1.cmp(v2),
414            (Date32(v1), Null) => {
415                if v1.is_none() {
416                    Ordering::Equal
417                } else {
418                    Ordering::Greater
419                }
420            }
421            (Date32(_), _) => panic!("Attempt to compare Date32 with non-Date32"),
422            (Date64(v1), Date64(v2)) => v1.cmp(v2),
423            (Date64(v1), Null) => {
424                if v1.is_none() {
425                    Ordering::Equal
426                } else {
427                    Ordering::Greater
428                }
429            }
430            (Date64(_), _) => panic!("Attempt to compare Date64 with non-Date64"),
431            (Time32Second(v1), Time32Second(v2)) => v1.cmp(v2),
432            (Time32Second(v1), Null) => {
433                if v1.is_none() {
434                    Ordering::Equal
435                } else {
436                    Ordering::Greater
437                }
438            }
439            (Time32Second(_), _) => panic!("Attempt to compare Time32Second with non-Time32Second"),
440            (Time32Millisecond(v1), Time32Millisecond(v2)) => v1.cmp(v2),
441            (Time32Millisecond(v1), Null) => {
442                if v1.is_none() {
443                    Ordering::Equal
444                } else {
445                    Ordering::Greater
446                }
447            }
448            (Time32Millisecond(_), _) => {
449                panic!("Attempt to compare Time32Millisecond with non-Time32Millisecond")
450            }
451            (Time64Microsecond(v1), Time64Microsecond(v2)) => v1.cmp(v2),
452            (Time64Microsecond(v1), Null) => {
453                if v1.is_none() {
454                    Ordering::Equal
455                } else {
456                    Ordering::Greater
457                }
458            }
459            (Time64Microsecond(_), _) => {
460                panic!("Attempt to compare Time64Microsecond with non-Time64Microsecond")
461            }
462            (Time64Nanosecond(v1), Time64Nanosecond(v2)) => v1.cmp(v2),
463            (Time64Nanosecond(v1), Null) => {
464                if v1.is_none() {
465                    Ordering::Equal
466                } else {
467                    Ordering::Greater
468                }
469            }
470            (Time64Nanosecond(_), _) => {
471                panic!("Attempt to compare Time64Nanosecond with non-Time64Nanosecond")
472            }
473            (TimestampSecond(v1, _), TimestampSecond(v2, _)) => v1.cmp(v2),
474            (TimestampSecond(v1, _), Null) => {
475                if v1.is_none() {
476                    Ordering::Equal
477                } else {
478                    Ordering::Greater
479                }
480            }
481            (TimestampSecond(_, _), _) => {
482                panic!("Attempt to compare TimestampSecond with non-TimestampSecond")
483            }
484            (TimestampMillisecond(v1, _), TimestampMillisecond(v2, _)) => v1.cmp(v2),
485            (TimestampMillisecond(v1, _), Null) => {
486                if v1.is_none() {
487                    Ordering::Equal
488                } else {
489                    Ordering::Greater
490                }
491            }
492            (TimestampMillisecond(_, _), _) => {
493                panic!("Attempt to compare TimestampMillisecond with non-TimestampMillisecond")
494            }
495            (TimestampMicrosecond(v1, _), TimestampMicrosecond(v2, _)) => v1.cmp(v2),
496            (TimestampMicrosecond(v1, _), Null) => {
497                if v1.is_none() {
498                    Ordering::Equal
499                } else {
500                    Ordering::Greater
501                }
502            }
503            (TimestampMicrosecond(_, _), _) => {
504                panic!("Attempt to compare TimestampMicrosecond with non-TimestampMicrosecond")
505            }
506            (TimestampNanosecond(v1, _), TimestampNanosecond(v2, _)) => v1.cmp(v2),
507            (TimestampNanosecond(v1, _), Null) => {
508                if v1.is_none() {
509                    Ordering::Equal
510                } else {
511                    Ordering::Greater
512                }
513            }
514            (TimestampNanosecond(_, _), _) => {
515                panic!("Attempt to compare TimestampNanosecond with non-TimestampNanosecond")
516            }
517            (IntervalYearMonth(v1), IntervalYearMonth(v2)) => v1.cmp(v2),
518            (IntervalYearMonth(v1), Null) => {
519                if v1.is_none() {
520                    Ordering::Equal
521                } else {
522                    Ordering::Greater
523                }
524            }
525            (IntervalYearMonth(_), _) => {
526                panic!("Attempt to compare IntervalYearMonth with non-IntervalYearMonth")
527            }
528            (IntervalDayTime(v1), IntervalDayTime(v2)) => v1.cmp(v2),
529            (IntervalDayTime(v1), Null) => {
530                if v1.is_none() {
531                    Ordering::Equal
532                } else {
533                    Ordering::Greater
534                }
535            }
536            (IntervalDayTime(_), _) => {
537                panic!("Attempt to compare IntervalDayTime with non-IntervalDayTime")
538            }
539            (IntervalMonthDayNano(v1), IntervalMonthDayNano(v2)) => v1.cmp(v2),
540            (IntervalMonthDayNano(v1), Null) => {
541                if v1.is_none() {
542                    Ordering::Equal
543                } else {
544                    Ordering::Greater
545                }
546            }
547            (IntervalMonthDayNano(_), _) => {
548                panic!("Attempt to compare IntervalMonthDayNano with non-IntervalMonthDayNano")
549            }
550            (DurationSecond(v1), DurationSecond(v2)) => v1.cmp(v2),
551            (DurationSecond(v1), Null) => {
552                if v1.is_none() {
553                    Ordering::Equal
554                } else {
555                    Ordering::Greater
556                }
557            }
558            (DurationSecond(_), _) => {
559                panic!("Attempt to compare DurationSecond with non-DurationSecond")
560            }
561            (DurationMillisecond(v1), DurationMillisecond(v2)) => v1.cmp(v2),
562            (DurationMillisecond(v1), Null) => {
563                if v1.is_none() {
564                    Ordering::Equal
565                } else {
566                    Ordering::Greater
567                }
568            }
569            (DurationMillisecond(_), _) => {
570                panic!("Attempt to compare DurationMillisecond with non-DurationMillisecond")
571            }
572            (DurationMicrosecond(v1), DurationMicrosecond(v2)) => v1.cmp(v2),
573            (DurationMicrosecond(v1), Null) => {
574                if v1.is_none() {
575                    Ordering::Equal
576                } else {
577                    Ordering::Greater
578                }
579            }
580            (DurationMicrosecond(_), _) => {
581                panic!("Attempt to compare DurationMicrosecond with non-DurationMicrosecond")
582            }
583            (DurationNanosecond(v1), DurationNanosecond(v2)) => v1.cmp(v2),
584            (DurationNanosecond(v1), Null) => {
585                if v1.is_none() {
586                    Ordering::Equal
587                } else {
588                    Ordering::Greater
589                }
590            }
591            (DurationNanosecond(_), _) => {
592                panic!("Attempt to compare DurationNanosecond with non-DurationNanosecond")
593            }
594            (Struct(_arr), Struct(_arr2)) => todo!(),
595            (Struct(arr), Null) => {
596                if arr.is_empty() {
597                    Ordering::Equal
598                } else {
599                    Ordering::Greater
600                }
601            }
602            (Struct(_arr), _) => panic!("Attempt to compare Struct with non-Struct"),
603            (Dictionary(_k1, v1), Dictionary(_k2, v2)) => Self(*v1.clone()).cmp(&Self(*v2.clone())),
604            (Dictionary(_, v1), Null) => Self(*v1.clone()).cmp(&Self(ScalarValue::Null)),
605            (Dictionary(_, _), _) => panic!("Attempt to compare Dictionary with non-Dictionary"),
606            // What would a btree of unions even look like?  May not be possible.
607            (Union(_, _, _), _) => todo!("Support for union scalars"),
608            (RunEndEncoded(_, _, _), _) => {
609                todo!("Support for run-end encoded scalars")
610            }
611            (Null, Null) => Ordering::Equal,
612            (Null, _) => todo!(),
613        }
614    }
615}
616
617/// Returns the first index `i` in `[lo, hi)` for which `pred(i)` is `false`.
618///
619/// `pred` must be `true` for a (possibly empty) prefix of the range and `false`
620/// for the rest, i.e. the range is partitioned by `pred`.
621fn partition_point(lo: usize, hi: usize, mut pred: impl FnMut(usize) -> bool) -> usize {
622    let mut lo = lo;
623    let mut hi = hi;
624    while lo < hi {
625        let mid = lo + (hi - lo) / 2;
626        if pred(mid) {
627            lo = mid + 1;
628        } else {
629            hi = mid;
630        }
631    }
632    lo
633}
634
635/// Builds a comparator over two array accessors of the same `Ord` item type,
636/// matching arrow's NULLs-first ascending order (`null < non-null`, `null == null`).
637///
638/// Unlike [`make_comparator`], the returned closure is generic (not boxed), so the
639/// element comparison inlines into the scan instead of dispatching through a vtable
640/// on every call.
641fn accessor_cmp<'a, T, L, R>(left: L, right: R) -> impl Fn(usize, usize) -> Ordering + 'a
642where
643    T: Ord,
644    L: ArrayAccessor<Item = T> + 'a,
645    R: ArrayAccessor<Item = T> + 'a,
646{
647    move |i, j| match (left.is_null(i), right.is_null(j)) {
648        (true, true) => Ordering::Equal,
649        (true, false) => Ordering::Less,
650        (false, true) => Ordering::Greater,
651        (false, false) => left.value(i).cmp(&right.value(j)),
652    }
653}
654
655/// Views `arr` as `PrimitiveArray<K>` for comparison. Zero-copy (shared buffers)
656/// when `arr` already has type `K`; otherwise — a logical type whose physical
657/// storage is `K::Native`, e.g. `Date32`/`Time32` over `i32` or `Timestamp`/
658/// `Duration` over `i64` — the array data is relabeled to `K` without copying the
659/// values, so all such logical types share one comparison path.
660fn reinterpret_primitive<K: ArrowPrimitiveType>(arr: &dyn Array) -> Result<PrimitiveArray<K>> {
661    if let Some(arr) = arr.as_primitive_opt::<K>() {
662        return Ok(arr.clone());
663    }
664    let data = arr
665        .to_data()
666        .into_builder()
667        .data_type(K::DATA_TYPE)
668        .build()
669        .map_err(|e| {
670            Error::internal(format!(
671                "failed to reinterpret {} as {}: {e}",
672                arr.data_type(),
673                K::DATA_TYPE
674            ))
675        })?;
676    Ok(PrimitiveArray::<K>::from(data))
677}
678
679/// Like [`accessor_cmp`] but for primitive columns, comparing native values with
680/// [`ArrowNativeTypeOp::compare`] (total order, so floats match arrow's NaN-last
681/// `make_comparator` ordering).
682fn primitive_cmp<'a, T>(
683    left: &'a PrimitiveArray<T>,
684    right: &'a PrimitiveArray<T>,
685) -> impl Fn(usize, usize) -> Ordering + 'a
686where
687    T: ArrowPrimitiveType,
688{
689    move |i, j| match (left.is_null(i), right.is_null(j)) {
690        (true, true) => Ordering::Equal,
691        (true, false) => Ordering::Less,
692        (false, true) => Ordering::Greater,
693        (false, false) => left.value(i).compare(right.value(j)),
694    }
695}
696
697/// Satisfies scalar queries by searching the `page_lookup.lance` batch directly.
698///
699/// The batch holds one row per page with columns `min | max | null_count | page_idx`,
700/// sorted ascending by `min` with NULLs first (the order the index is trained in).
701/// Both query paths binary-search the sorted `min` column for a starting row and
702/// scan forward filtering by `max`:
703///
704/// - Equality / `IN` (`candidate_pages_for_values`) dispatch on the query's
705///   *physical storage type* to a monomorphized, inlined comparator: numerics go
706///   through `scan_native` (logical types sharing a native — e.g. `Date32` and
707///   `Int32` — fold to one path), byte-likes through `scan_accessor`. Only types
708///   without a native fast path (struct-backed intervals, booleans) fall back to the
709///   boxed [`make_comparator`] via `scan_fallback`.
710/// - Range searches (`pages_between`) currently use [`make_comparator`] directly.
711#[derive(Debug, PartialEq, DeepSizeOf)]
712pub struct BTreeLookup {
713    /// One row per page (`min | max | null_count | page_idx`), sorted by `min`.
714    batch: RecordBatch,
715    /// Pages with at least one null value (does not include `all_null_pages`).
716    null_pages: Vec<u32>,
717    /// Pages that are entirely null.
718    all_null_pages: Vec<u32>,
719    /// Index of the first row whose `max` is non-null. Entirely-null pages sort to
720    /// the front (NULLs first) and are skipped when searching value ranges.
721    search_start: usize,
722}
723
724#[derive(Debug, Copy, Clone, PartialEq, Eq)]
725enum Matches {
726    Some(u32),
727    All(u32),
728}
729
730impl Matches {
731    fn page_id(&self) -> u32 {
732        match self {
733            Self::Some(page_id) => *page_id,
734            Self::All(page_id) => *page_id,
735        }
736    }
737}
738
739impl BTreeLookup {
740    /// Build a lookup over the `page_lookup.lance` batch. The batch is retained as
741    /// the source of truth; only the small null-page index lists are precomputed.
742    fn try_new(batch: RecordBatch) -> Result<Self> {
743        let mut null_pages = Vec::new();
744        let mut all_null_pages = Vec::new();
745        let mut search_start = batch.num_rows();
746
747        if batch.num_rows() > 0 {
748            let maxs = batch.column(1);
749            let null_counts = batch
750                .column(2)
751                .as_any()
752                .downcast_ref::<UInt32Array>()
753                .ok_or_else(|| Error::internal("BTree lookup null_count column must be UInt32"))?;
754            let page_numbers = batch
755                .column(3)
756                .as_any()
757                .downcast_ref::<UInt32Array>()
758                .ok_or_else(|| Error::internal("BTree lookup page_idx column must be UInt32"))?;
759
760            for idx in 0..batch.num_rows() {
761                let page_number = page_numbers.values()[idx];
762                // An entirely-null page has a null `max`; it is never searched by value.
763                if maxs.is_null(idx) {
764                    all_null_pages.push(page_number);
765                    continue;
766                }
767                if search_start == batch.num_rows() {
768                    search_start = idx;
769                }
770                if null_counts.values()[idx] > 0 {
771                    null_pages.push(page_number);
772                }
773            }
774        } else {
775            search_start = 0;
776        }
777
778        Ok(Self {
779            batch,
780            null_pages,
781            all_null_pages,
782            search_start,
783        })
784    }
785
786    fn page_numbers(&self) -> Result<&UInt32Array> {
787        self.batch
788            .column(3)
789            .as_any()
790            .downcast_ref::<UInt32Array>()
791            .ok_or_else(|| Error::internal("BTree lookup page_idx column must be UInt32"))
792    }
793
794    // All pages that could have a value equal to val
795    fn pages_eq(&self, query: &OrderableScalarValue) -> Result<Vec<Matches>> {
796        if query.0.is_null() {
797            Ok(self.pages_null())
798        } else {
799            let query_arr = query.0.to_array_of_size(1)?;
800            let pages = self.candidate_pages_for_values(query_arr.as_ref())?;
801            Ok(pages.into_iter().map(Matches::Some).collect())
802        }
803    }
804
805    // All pages that could have a value equal to one of the values
806    fn pages_in(
807        &self,
808        values: impl IntoIterator<Item = OrderableScalarValue>,
809    ) -> Result<Vec<Matches>> {
810        // Equality lookups never produce a full-page (`Matches::All`) match because a
811        // single value cannot cover an entire page's range, so every candidate is
812        // `Matches::Some`. Refining this for low-cardinality data is the TODO in
813        // `pages_between`.
814        let values = values.into_iter();
815        let mut has_null = false;
816        let mut non_null = Vec::with_capacity(values.size_hint().0);
817        for val in values {
818            if val.0.is_null() {
819                has_null = true;
820            } else {
821                non_null.push(val.0);
822            }
823        }
824
825        // Build a single array holding every queried value so the comparators are
826        // constructed once and reused across all of them, rather than per value.
827        let mut all_pages = if non_null.is_empty() {
828            Vec::new()
829        } else {
830            let query_arr = ScalarValue::iter_to_array(non_null)?;
831            self.candidate_pages_for_values(query_arr.as_ref())?
832        };
833        if has_null {
834            all_pages.extend(self.pages_null().into_iter().map(|m| m.page_id()));
835        }
836        all_pages.sort_unstable();
837        all_pages.dedup();
838        Ok(all_pages.into_iter().map(Matches::Some).collect())
839    }
840
841    /// Candidate page numbers (deduped, ascending) for an equality search against
842    /// every value in `query`. A page is a candidate when its `[min, max]` range
843    /// could contain the value, i.e. `min <= value <= max`.
844    ///
845    /// The comparators are built once over the whole `query` array and reused for
846    /// each value, so an N-value `IN` costs three comparator constructions instead
847    /// of three per value.
848    fn candidate_pages_for_values(&self, query: &dyn Array) -> Result<Vec<u32>> {
849        let num_rows = self.batch.num_rows();
850        if self.search_start >= num_rows || query.is_empty() {
851            return Ok(vec![]);
852        }
853
854        let mins = self.batch.column(0).as_ref();
855        let maxs = self.batch.column(1).as_ref();
856        let page_ids = self.page_numbers()?.values();
857
858        // Compare against the page columns with a native, monomorphized comparator
859        // that inlines, rather than the boxed `DynComparator` from `make_comparator`
860        // (one vtable call per comparison). Logical types that share a physical
861        // storage type route to one path via a zero-copy reinterpret, so e.g. every
862        // date/time/timestamp/duration type reuses the `i32`/`i64` path instead of
863        // generating its own. Types with no native path (intervals with struct
864        // natives, booleans, ...) take the `make_comparator` fallback. The query
865        // array always matches the column type, so its type selects the branch.
866        use DataType::*;
867        match query.data_type() {
868            Int8 => self.scan_native::<Int8Type>(mins, maxs, query, page_ids),
869            Int16 => self.scan_native::<Int16Type>(mins, maxs, query, page_ids),
870            // i32-backed: Int32, Date32, Time32, Decimal32, year-month intervals.
871            Int32 | Date32 | Time32(_) | Decimal32(_, _) | Interval(IntervalUnit::YearMonth) => {
872                self.scan_native::<Int32Type>(mins, maxs, query, page_ids)
873            }
874            // i64-backed: Int64, Date64, Time64, Timestamp, Duration, Decimal64.
875            Int64 | Date64 | Time64(_) | Timestamp(_, _) | Duration(_) | Decimal64(_, _) => {
876                self.scan_native::<Int64Type>(mins, maxs, query, page_ids)
877            }
878            UInt8 => self.scan_native::<UInt8Type>(mins, maxs, query, page_ids),
879            UInt16 => self.scan_native::<UInt16Type>(mins, maxs, query, page_ids),
880            UInt32 => self.scan_native::<UInt32Type>(mins, maxs, query, page_ids),
881            UInt64 => self.scan_native::<UInt64Type>(mins, maxs, query, page_ids),
882            Float16 => self.scan_native::<Float16Type>(mins, maxs, query, page_ids),
883            Float32 => self.scan_native::<Float32Type>(mins, maxs, query, page_ids),
884            Float64 => self.scan_native::<Float64Type>(mins, maxs, query, page_ids),
885            Decimal128(_, _) => self.scan_native::<Decimal128Type>(mins, maxs, query, page_ids),
886            Decimal256(_, _) => self.scan_native::<Decimal256Type>(mins, maxs, query, page_ids),
887            Utf8 => Ok(self.scan_accessor(
888                mins.as_string::<i32>(),
889                maxs.as_string::<i32>(),
890                query.as_string::<i32>(),
891                page_ids,
892            )),
893            LargeUtf8 => Ok(self.scan_accessor(
894                mins.as_string::<i64>(),
895                maxs.as_string::<i64>(),
896                query.as_string::<i64>(),
897                page_ids,
898            )),
899            Binary => Ok(self.scan_accessor(
900                mins.as_binary::<i32>(),
901                maxs.as_binary::<i32>(),
902                query.as_binary::<i32>(),
903                page_ids,
904            )),
905            LargeBinary => Ok(self.scan_accessor(
906                mins.as_binary::<i64>(),
907                maxs.as_binary::<i64>(),
908                query.as_binary::<i64>(),
909                page_ids,
910            )),
911            FixedSizeBinary(_) => Ok(self.scan_accessor(
912                mins.as_fixed_size_binary(),
913                maxs.as_fixed_size_binary(),
914                query.as_fixed_size_binary(),
915                page_ids,
916            )),
917            _ => self.scan_fallback(mins, maxs, query, page_ids),
918        }
919    }
920
921    /// Native-comparator equality scan for a primitive physical type `K`. The page
922    /// columns and `query` are reinterpreted to `PrimitiveArray<K>` (zero-copy when
923    /// already that type) and compared with [`primitive_cmp`].
924    fn scan_native<K: ArrowPrimitiveType>(
925        &self,
926        mins: &dyn Array,
927        maxs: &dyn Array,
928        query: &dyn Array,
929        page_ids: &[u32],
930    ) -> Result<Vec<u32>> {
931        let mins = reinterpret_primitive::<K>(mins)?;
932        let maxs = reinterpret_primitive::<K>(maxs)?;
933        let query = reinterpret_primitive::<K>(query)?;
934        Ok(self.scan_equality_pages(
935            query.len(),
936            page_ids,
937            |idx| maxs.is_null(idx),
938            primitive_cmp(&mins, &query),
939            primitive_cmp(&maxs, &query),
940            primitive_cmp(&mins, &mins),
941        ))
942    }
943
944    /// Native-comparator equality scan for byte-like columns (`Utf8`/`Binary`/
945    /// `FixedSizeBinary` and their large variants), compared lexicographically via
946    /// [`accessor_cmp`].
947    fn scan_accessor<T, A>(&self, mins: A, maxs: A, query: A, page_ids: &[u32]) -> Vec<u32>
948    where
949        T: Ord,
950        A: ArrayAccessor<Item = T> + Copy,
951    {
952        self.scan_equality_pages(
953            query.len(),
954            page_ids,
955            |idx| maxs.is_null(idx),
956            accessor_cmp(mins, query),
957            accessor_cmp(maxs, query),
958            accessor_cmp(mins, mins),
959        )
960    }
961
962    /// Fallback equality scan for types without a native path (intervals with struct
963    /// natives, booleans, ...), using arrow's boxed `make_comparator`.
964    fn scan_fallback(
965        &self,
966        mins: &dyn Array,
967        maxs: &dyn Array,
968        query: &dyn Array,
969        page_ids: &[u32],
970    ) -> Result<Vec<u32>> {
971        // The batch is sorted ascending by `min` with NULLs first; compare the query
972        // values the same way so the binary searches stay consistent.
973        let opts = SortOptions {
974            descending: false,
975            nulls_first: true,
976        };
977        let cmp_min = make_comparator(mins, query, opts)?;
978        let cmp_max = make_comparator(maxs, query, opts)?;
979        let cmp_min_min = make_comparator(mins, mins, opts)?;
980        Ok(self.scan_equality_pages(
981            query.len(),
982            page_ids,
983            |idx| maxs.is_null(idx),
984            cmp_min,
985            cmp_max,
986            cmp_min_min,
987        ))
988    }
989
990    /// Binary-search + forward-scan the page batch for equality candidates.
991    ///
992    /// Monomorphized over the comparator closures so a typed-native comparator
993    /// inlines (no per-call vtable dispatch). The closures encode NULLs-first,
994    /// ascending order:
995    ///   * `max_is_null(i)` — whether page `i`'s `max` is null (an all-null page)
996    ///   * `cmp_min(i, j)` — page `i`'s `min` vs query value `j`
997    ///   * `cmp_max(i, j)` — page `i`'s `max` vs query value `j`
998    ///   * `cmp_min_min(i, anchor)` — two page `min`s, to expand left onto a straddle
999    fn scan_equality_pages(
1000        &self,
1001        num_query: usize,
1002        page_ids: &[u32],
1003        max_is_null: impl Fn(usize) -> bool,
1004        cmp_min: impl Fn(usize, usize) -> Ordering,
1005        cmp_max: impl Fn(usize, usize) -> Ordering,
1006        cmp_min_min: impl Fn(usize, usize) -> Ordering,
1007    ) -> Vec<u32> {
1008        let num_rows = self.batch.num_rows();
1009        // High-cardinality lookups hit ~one page per value; presize to avoid the
1010        // element-by-element `RawVec` growth that profiling flagged.
1011        let mut pages = Vec::with_capacity(num_query);
1012        for j in 0..num_query {
1013            // Start row: peek a little to the left of the value. A query for 7 must
1014            // still reach a page like [5, 10], so we include every page whose `min`
1015            // equals the largest `min` strictly less than the value.
1016            let p = partition_point(0, num_rows, |i| cmp_min(i, j) == Ordering::Less);
1017            let start = if p == 0 {
1018                self.search_start
1019            } else {
1020                let anchor = p - 1;
1021                partition_point(0, p, |i| cmp_min_min(i, anchor) == Ordering::Less)
1022            }
1023            .max(self.search_start);
1024
1025            // End row: pages whose `min` exceeds the value cannot match.
1026            let end = partition_point(start, num_rows, |i| cmp_min(i, j) != Ordering::Greater);
1027
1028            // The window splits at `p` (first row with `min >= value`):
1029            //   * `[start, p)` — the peek-left/straddle region (`min < value`). A page
1030            //     here matches only if its `max` reaches the value, so it needs the
1031            //     filter, and it may include a null-`min`/null-`max` straddle page.
1032            //   * `[p, end)` — rows with `min == value`. These always match (`max >=
1033            //     min == value`) and can't have a null `max` (all-null pages sort to
1034            //     the front, before `search_start <= start`), so we copy them in one
1035            //     slice instead of pushing per row.
1036            let bulk_start = p.max(start);
1037            for (offset, &page_id) in page_ids[start..bulk_start].iter().enumerate() {
1038                let idx = start + offset;
1039                // All-null pages are only matched by IS NULL queries.
1040                if max_is_null(idx) {
1041                    continue;
1042                }
1043                // Candidate when the page's `max` reaches the value (`max >= value`).
1044                if cmp_max(idx, j) != Ordering::Less {
1045                    pages.push(page_id);
1046                }
1047            }
1048            pages.extend_from_slice(&page_ids[bulk_start..end]);
1049        }
1050
1051        pages.sort_unstable();
1052        pages.dedup();
1053        pages
1054    }
1055
1056    // All pages that could have a value in the range
1057    fn pages_between(
1058        &self,
1059        range: (Bound<&OrderableScalarValue>, Bound<&OrderableScalarValue>),
1060    ) -> Result<Vec<Matches>> {
1061        let num_rows = self.batch.num_rows();
1062        // No searchable (non-all-null) pages.
1063        if self.search_start >= num_rows {
1064            return Ok(vec![]);
1065        }
1066
1067        let mins = self.batch.column(0).as_ref();
1068        let maxs = self.batch.column(1).as_ref();
1069        let page_numbers = self.page_numbers()?;
1070
1071        // The batch is sorted ascending by `min` with NULLs first; compare bounds
1072        // the same way so the binary searches and the null `min` of a straddling
1073        // page are handled consistently.
1074        let opts = SortOptions {
1075            descending: false,
1076            nulls_first: true,
1077        };
1078        // Bounds become 1-row arrays of the column type so arrow's type-dispatched
1079        // comparator can compare them against the `min`/`max` columns.
1080        let lower_arr = match range.0 {
1081            Bound::Unbounded => None,
1082            Bound::Included(v) | Bound::Excluded(v) => Some(v.0.to_array_of_size(1)?),
1083        };
1084        let upper_arr = match range.1 {
1085            Bound::Unbounded => None,
1086            Bound::Included(v) | Bound::Excluded(v) => Some(v.0.to_array_of_size(1)?),
1087        };
1088
1089        // Start row: peek a little to the left of the lower bound. A query for 7
1090        // must still reach a page like [5, 10], so we include every page whose
1091        // `min` equals the largest `min` strictly less than the lower bound.
1092        let start = match &lower_arr {
1093            None => self.search_start,
1094            Some(lower) => {
1095                let cmp = make_comparator(mins, lower.as_ref(), opts)?;
1096                // first row with min >= lower
1097                let p = partition_point(0, num_rows, |i| cmp(i, 0) == Ordering::Less);
1098                if p == 0 {
1099                    self.search_start
1100                } else {
1101                    // first row sharing the straddling page's `min`
1102                    let straddle = mins.slice(p - 1, 1);
1103                    let cmp = make_comparator(mins, straddle.as_ref(), opts)?;
1104                    partition_point(0, p, |i| cmp(i, 0) == Ordering::Less)
1105                }
1106            }
1107        }
1108        .max(self.search_start);
1109
1110        // End row: pages whose `min` exceeds the upper bound cannot match. The
1111        // upper bound is treated as inclusive even when the query bound is
1112        // exclusive, so an [x, x) query still reaches a page whose `min` == x.
1113        let end = match &upper_arr {
1114            None => num_rows,
1115            Some(upper) => {
1116                let cmp = make_comparator(mins, upper.as_ref(), opts)?;
1117                partition_point(start, num_rows, |i| cmp(i, 0) != Ordering::Greater)
1118            }
1119        };
1120
1121        if start >= end {
1122            return Ok(vec![]);
1123        }
1124
1125        // Comparators reused across the candidate rows.
1126        let cmp_max_lower = lower_arr
1127            .as_ref()
1128            .map(|l| make_comparator(maxs, l.as_ref(), opts))
1129            .transpose()?;
1130        let cmp_min_lower = lower_arr
1131            .as_ref()
1132            .map(|l| make_comparator(mins, l.as_ref(), opts))
1133            .transpose()?;
1134        let cmp_max_upper = upper_arr
1135            .as_ref()
1136            .map(|u| make_comparator(maxs, u.as_ref(), opts))
1137            .transpose()?;
1138
1139        let mut matches = Vec::new();
1140        for idx in start..end {
1141            // All-null pages are only matched by IS NULL queries.
1142            if maxs.is_null(idx) {
1143                continue;
1144            }
1145
1146            // Candidate filter: the page's `max` reaches the lower bound.
1147            let lower_ok = match (range.0, &cmp_max_lower) {
1148                (Bound::Unbounded, _) => true,
1149                (Bound::Included(_), Some(cmp)) => cmp(idx, 0) != Ordering::Less, // max >= lower
1150                (Bound::Excluded(_), Some(cmp)) => cmp(idx, 0) == Ordering::Greater, // max > lower
1151                _ => unreachable!("lower bound and its comparator are constructed together"),
1152            };
1153            if !lower_ok {
1154                continue;
1155            }
1156
1157            let page_number = page_numbers.values()[idx];
1158
1159            // A page with a null `min` straddles the NULL/non-NULL boundary, so it
1160            // is only ever a partial match.
1161            if mins.is_null(idx) {
1162                matches.push(Matches::Some(page_number));
1163                continue;
1164            }
1165
1166            // Full match requires the page to sit entirely within the query range.
1167            let lower_full = match (range.0, &cmp_min_lower) {
1168                (Bound::Unbounded, _) => true,
1169                (Bound::Included(_), Some(cmp)) => cmp(idx, 0) != Ordering::Less, // min >= lower
1170                (Bound::Excluded(_), Some(cmp)) => cmp(idx, 0) == Ordering::Greater, // min > lower
1171                _ => unreachable!("lower bound and its comparator are constructed together"),
1172            };
1173            let upper_full = match (range.1, &cmp_max_upper) {
1174                (Bound::Unbounded, _) => true,
1175                (Bound::Included(_), Some(cmp)) => cmp(idx, 0) != Ordering::Greater, // max <= upper
1176                (Bound::Excluded(_), Some(cmp)) => cmp(idx, 0) == Ordering::Less,    // max < upper
1177                _ => unreachable!("upper bound and its comparator are constructed together"),
1178            };
1179            if lower_full && upper_full {
1180                matches.push(Matches::All(page_number));
1181            } else {
1182                matches.push(Matches::Some(page_number));
1183            }
1184        }
1185
1186        Ok(matches)
1187    }
1188
1189    fn pages_null(&self) -> Vec<Matches> {
1190        self.null_pages
1191            .iter()
1192            .copied()
1193            .map(Matches::Some)
1194            .chain(self.all_null_pages.iter().copied().map(Matches::All))
1195            .collect()
1196    }
1197}
1198
1199// We only need to open a file reader for pages if we need to load a page.  If all
1200// pages are cached we don't open it.  If we do open it we should only open it once.
1201#[derive(Clone)]
1202struct LazyIndexReader {
1203    index_reader: Arc<tokio::sync::Mutex<Option<Arc<dyn IndexReader>>>>,
1204    store: Arc<dyn IndexStore>,
1205    ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1206}
1207
1208impl LazyIndexReader {
1209    fn new(
1210        store: Arc<dyn IndexStore>,
1211        ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1212    ) -> Self {
1213        Self {
1214            index_reader: Arc::new(tokio::sync::Mutex::new(None)),
1215            store,
1216            ranges_to_files,
1217        }
1218    }
1219
1220    async fn get(&self) -> Result<Arc<dyn IndexReader>> {
1221        let mut reader = self.index_reader.lock().await;
1222        if reader.is_none() {
1223            let index_reader = if let Some(ranges_to_files) = &self.ranges_to_files {
1224                Arc::new(LazyRangedIndexReader::new(
1225                    self.store.clone(),
1226                    ranges_to_files.clone(),
1227                ))
1228            } else {
1229                self.store.open_index_file(BTREE_PAGES_NAME).await?
1230            };
1231            *reader = Some(index_reader);
1232        }
1233        Ok(reader.as_ref().unwrap().clone())
1234    }
1235}
1236
1237/// Index reader to dispatch page query to corresponding ranged page-files.
1238struct LazyRangedIndexReader {
1239    #[allow(clippy::type_complexity)]
1240    readers:
1241        Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::OnceCell<Arc<dyn IndexReader>>>>>>,
1242    store: Arc<dyn IndexStore>,
1243    ranges_to_files: Arc<RangeInclusiveMap<u32, (String, u32)>>,
1244}
1245
1246impl LazyRangedIndexReader {
1247    fn new(
1248        store: Arc<dyn IndexStore>,
1249        ranges_to_files: Arc<RangeInclusiveMap<u32, (String, u32)>>,
1250    ) -> Self {
1251        Self {
1252            readers: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1253            store,
1254            ranges_to_files,
1255        }
1256    }
1257
1258    async fn get_reader(&self, file_name: &str) -> Result<Arc<dyn IndexReader>> {
1259        let reader_cell = {
1260            let mut guard = self.readers.lock().await;
1261            guard
1262                .entry(file_name.to_string())
1263                .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
1264                .clone()
1265        };
1266        let reader = reader_cell
1267            .get_or_try_init(|| async { self.store.open_index_file(file_name).await })
1268            .await?;
1269        Ok(reader.clone())
1270    }
1271
1272    async fn get_reader_and_local_page_idx(
1273        &self,
1274        page_idx: u32,
1275    ) -> Result<(Arc<dyn IndexReader>, u32)> {
1276        let (page_file_name, offset) = self.ranges_to_files.get(&page_idx).ok_or_else(|| {
1277            Error::internal(format!(
1278                "Unexpected page index, index {} is out of range.",
1279                page_idx
1280            ))
1281        })?;
1282        let reader = self.get_reader(page_file_name).await?;
1283        Ok((reader.clone(), page_idx - *offset))
1284    }
1285}
1286
1287#[async_trait]
1288impl IndexReader for LazyRangedIndexReader {
1289    async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
1290        let (reader, local_page_idx) = self.get_reader_and_local_page_idx(n as u32).await?;
1291        reader
1292            .read_record_batch(local_page_idx as u64, batch_size)
1293            .await
1294    }
1295
1296    async fn read_range(
1297        &self,
1298        _range: std::ops::Range<usize>,
1299        _projection: Option<&[&str]>,
1300    ) -> Result<RecordBatch> {
1301        unimplemented!("Read range is not implemented for lazy page file reader.");
1302    }
1303
1304    async fn num_batches(&self, batch_size: u64) -> u32 {
1305        let mut total_batches = 0;
1306        for (_, (file_name, _)) in self.ranges_to_files.iter() {
1307            let reader = self
1308                .get_reader(file_name)
1309                .await
1310                .unwrap_or_else(|_| panic!("Cannot open page file {}.", file_name));
1311            total_batches += reader.as_ref().num_batches(batch_size).await;
1312        }
1313        total_batches
1314    }
1315
1316    fn num_rows(&self) -> usize {
1317        unimplemented!("only async functions are available for lazy page index reader.");
1318    }
1319
1320    fn schema(&self) -> &lance_core::datatypes::Schema {
1321        unimplemented!("only async functions are available for lazy page index reader.");
1322    }
1323}
1324
1325/// A btree index satisfies scalar queries using a b tree
1326///
1327/// The upper layers of the btree are expected to be cached and, when unloaded,
1328/// are stored in a btree structure in memory.  The leaves of the btree are left
1329/// to be searched by some other kind of index (currently a flat search).
1330///
1331/// This strikes a balance between an expensive memory structure containing all
1332/// of the values and an expensive disk structure that can't be efficiently searched.
1333///
1334/// For example, given 1Bi values we can store 256Ki leaves of size 4Ki.  We only
1335/// need memory space for 256Ki leaves (depends on the data type but usually a few MiB
1336/// at most) and can narrow our search to 4Ki values.
1337///
1338// Cache key implementation for type-safe cache access
1339#[derive(Debug, Clone, DeepSizeOf)]
1340pub struct CachedScalarIndex(Arc<dyn ScalarIndex>);
1341
1342impl CachedScalarIndex {
1343    pub fn new(index: Arc<dyn ScalarIndex>) -> Self {
1344        Self(index)
1345    }
1346
1347    pub fn into_inner(self) -> Arc<dyn ScalarIndex> {
1348        self.0
1349    }
1350}
1351
1352#[derive(Debug, Clone)]
1353pub struct BTreePageKey {
1354    pub page_number: u32,
1355}
1356
1357impl CacheKey for BTreePageKey {
1358    type ValueType = FlatIndex;
1359
1360    fn key(&self) -> std::borrow::Cow<'_, str> {
1361        format!("page-{}", self.page_number).into()
1362    }
1363
1364    fn type_name() -> &'static str {
1365        "BTreePage"
1366    }
1367
1368    fn schema() -> CacheKeySchema {
1369        CacheKeySchema::new("lance.scalar.btree-page-key", 1)
1370    }
1371
1372    fn write_key(&self, builder: &mut KeyBuilder) {
1373        builder.write_u32(self.page_number);
1374    }
1375
1376    fn codec() -> Option<CacheCodec> {
1377        // Pages are cached as `FlatIndex` values (see `ValueType` above).
1378        Some(CacheCodec::from_impl::<FlatIndex>())
1379    }
1380}
1381
1382/// The serializable state of a [`BTreeIndex`].
1383///
1384/// A `BTreeIndex` holds non-serializable infrastructure (an `IndexStore`, a
1385/// cache handle, a fragment-reuse index). `BTreeIndexState` captures just the
1386/// data needed to rebuild it: the `page_lookup.lance` batch (from which
1387/// `BTreeIndex::try_from_serialized` reconstructs the in-memory lookup with
1388/// no IO) plus the page batch size and range-partition map.
1389#[derive(Debug, Clone)]
1390struct BTreeIndexState {
1391    lookup_batch: RecordBatch,
1392    batch_size: u64,
1393    ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1394}
1395
1396impl DeepSizeOf for BTreeIndexState {
1397    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
1398        // `ranges_to_files` is tiny and `RangeInclusiveMap` is not `DeepSizeOf`;
1399        // the lookup batch dominates, matching how `BTreeIndex` accounts for itself.
1400        self.lookup_batch.deep_size_of_children(context)
1401    }
1402}
1403
1404impl BTreeIndexState {
1405    fn from_index(index: &dyn ScalarIndex) -> Result<Self> {
1406        let btree = index.as_any().downcast_ref::<BTreeIndex>().ok_or_else(|| {
1407            Error::internal("BTreeIndexState::from_index called with a non-BTree index")
1408        })?;
1409        Ok(Self {
1410            lookup_batch: btree.page_lookup.batch.clone(),
1411            batch_size: btree.batch_size,
1412            ranges_to_files: btree.ranges_to_files.clone(),
1413        })
1414    }
1415
1416    fn reconstruct(
1417        &self,
1418        store: Arc<dyn IndexStore>,
1419        index_cache: &LanceCache,
1420        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1421    ) -> Result<Arc<dyn ScalarIndex>> {
1422        let index = BTreeIndex::try_from_serialized(
1423            self.lookup_batch.clone(),
1424            store,
1425            index_cache,
1426            self.batch_size,
1427            self.ranges_to_files.clone(),
1428            frag_reuse_index,
1429        )?;
1430        Ok(Arc::new(index) as Arc<dyn ScalarIndex>)
1431    }
1432}
1433
1434impl CacheCodecImpl for BTreeIndexState {
1435    const TYPE_ID: &'static str = "lance.scalar.BTreeIndexState";
1436    const CURRENT_VERSION: u32 = 1;
1437
1438    /// Wire format:
1439    /// ```text
1440    /// HEADER    : BTreeIndexHeader proto (batch_size + page-range mapping)
1441    /// ARROW_IPC : page-lookup batch
1442    /// ```
1443    fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1444        let ranges_to_files = match &self.ranges_to_files {
1445            None => Vec::new(),
1446            Some(ranges) => ranges
1447                .iter()
1448                .map(|(range, (path, page_offset))| RangeToFile {
1449                    start: *range.start(),
1450                    end: *range.end(),
1451                    page_offset: *page_offset,
1452                    path: path.clone(),
1453                })
1454                .collect(),
1455        };
1456        let header = BTreeIndexHeader {
1457            batch_size: self.batch_size,
1458            has_ranges_to_files: self.ranges_to_files.is_some(),
1459            ranges_to_files,
1460        };
1461        w.write_header(&header)?;
1462        w.write_ipc(&self.lookup_batch)?;
1463        Ok(())
1464    }
1465
1466    fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
1467        let header: BTreeIndexHeader = r.read_header()?;
1468        let ranges_to_files = if header.has_ranges_to_files {
1469            let map: RangeInclusiveMap<u32, (String, u32)> = header
1470                .ranges_to_files
1471                .into_iter()
1472                .map(|entry| (entry.start..=entry.end, (entry.path, entry.page_offset)))
1473                .collect();
1474            Some(Arc::new(map))
1475        } else {
1476            None
1477        };
1478        let lookup_batch = r.read_ipc()?;
1479        Ok(Self {
1480            lookup_batch,
1481            batch_size: header.batch_size,
1482            ranges_to_files,
1483        })
1484    }
1485}
1486
1487/// Cache key for a [`BTreeIndexState`]. The cache it is used with is already
1488/// namespaced per-index, so the key string is a constant.
1489struct BTreeIndexStateKey;
1490
1491impl CacheKey for BTreeIndexStateKey {
1492    type ValueType = BTreeIndexState;
1493
1494    fn key(&self) -> std::borrow::Cow<'_, str> {
1495        "state".into()
1496    }
1497
1498    fn type_name() -> &'static str {
1499        "BTreeIndexState"
1500    }
1501
1502    fn schema() -> CacheKeySchema {
1503        CacheKeySchema::new("lance.scalar.btree-index-state-key", 1)
1504    }
1505
1506    fn write_key(&self, builder: &mut KeyBuilder) {
1507        builder.write_variant(0);
1508    }
1509
1510    fn codec() -> Option<CacheCodec> {
1511        Some(CacheCodec::from_impl::<BTreeIndexState>())
1512    }
1513}
1514
1515/// Note: this is very similar to the IVF index except we store the IVF part in a btree
1516/// for faster lookup
1517#[derive(Clone, Debug)]
1518pub struct BTreeIndex {
1519    page_lookup: Arc<BTreeLookup>,
1520    index_cache: WeakLanceCache,
1521    store: Arc<dyn IndexStore>,
1522    data_type: DataType,
1523    batch_size: u64,
1524
1525    /// A map that translates a global_page_idx stored in the single lookup file into the
1526    /// specific page file and local_page_idx.
1527    ///
1528    /// This is the key data structure used for efficiently reading data from a merged,
1529    /// range-partitioned index. It stores mappings from a contiguous range of global page
1530    /// indices to a tuple containing:
1531    ///
1532    /// 1. The path to the corresponding page file (e.g., `part_i_page_file.lance`).
1533    /// 2. The start offset that was used to calculate the local_page_idx for that partition.
1534    ///
1535    /// When a query needs to access a specific page using its `global_page_idx`:
1536    ///
1537    /// 1. The `global_page_idx` is used to look up its range in this `RangeInclusiveMap`,
1538    ///    and the map returns the `(file_path, start_offset)` tuple for that range.
1539    /// 3. The `local_page_idx` is calculated using the formula:
1540    ///    `local_page_idx = global_page_idx - start_offset`.
1541    /// 4. With the `file_path` and `local_page_idx`, the system can directly open the
1542    ///    correct partition file and read the specific page.
1543    ///
1544    /// # Example
1545    ///
1546    /// If the map contains an entry `(100..=199) => ("part_2_page_file.lance", 100)`, and we
1547    /// need to find `global_page_idx = 142`:
1548    ///
1549    /// - The map finds that 142 falls within the range `100..=199`, and it returns
1550    ///   `("part_2_page_file.lance", 100)`.
1551    /// - The local page_idx is calculated: `142 - 100 = 42`.
1552    /// - The system now knows to read page `42` from the file `part_2_page_file.lance`.
1553    ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1554    frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1555}
1556
1557impl DeepSizeOf for BTreeIndex {
1558    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
1559        // We don't include the index cache, or anything stored in it. For example:
1560        // sub_index and fri. `page_lookup` owns the lookup batch (the single source
1561        // of truth), so accounting for it covers the lookup data.
1562        self.page_lookup.deep_size_of_children(context) + self.store.deep_size_of_children(context)
1563    }
1564}
1565
1566impl BTreeIndex {
1567    #[allow(clippy::too_many_arguments)]
1568    fn new(
1569        page_lookup: Arc<BTreeLookup>,
1570        store: Arc<dyn IndexStore>,
1571        data_type: DataType,
1572        index_cache: WeakLanceCache,
1573        batch_size: u64,
1574        ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1575        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1576    ) -> Self {
1577        Self {
1578            page_lookup,
1579            store,
1580            data_type,
1581            index_cache,
1582            batch_size,
1583            ranges_to_files,
1584            frag_reuse_index,
1585        }
1586    }
1587
1588    /// For each key in `keys`, whether this index contains it — a batched
1589    /// existence check returning a mask aligned to `keys`.
1590    ///
1591    /// The per-key sibling of `search(Equals(..))`, but one call replaces N
1592    /// probes: keys are grouped by page using the same page resolution as
1593    /// [`ScalarIndex::search`] (`pages_eq`), each touched page is loaded once
1594    /// (session-cached), and membership is tested against the page's values via
1595    /// `FlatIndex::contains_values`. Avoids the per-key `SearchResult` /
1596    /// `RowAddrTreeMap` allocation when the caller only wants a yes/no.
1597    ///
1598    /// Intended for primary-key dedup, where keys are non-null; a null key maps
1599    /// to `false`.
1600    pub async fn contains_keys(
1601        &self,
1602        keys: &[ScalarValue],
1603        metrics: &dyn MetricsCollector,
1604    ) -> Result<Vec<bool>> {
1605        // Group each key (by input position) under every page whose value range
1606        // could hold it. Mirrors `search`'s page selection so the two agree.
1607        let mut by_page: HashMap<u32, Vec<(usize, OrderableScalarValue)>> = HashMap::new();
1608        for (idx, key) in keys.iter().enumerate() {
1609            if key.is_null() {
1610                continue;
1611            }
1612            let ov = OrderableScalarValue(key.clone());
1613            for matches in self.page_lookup.pages_eq(&ov)? {
1614                by_page
1615                    .entry(matches.page_id())
1616                    .or_default()
1617                    .push((idx, ov.clone()));
1618            }
1619        }
1620
1621        let index_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
1622        let page_tasks = by_page.into_iter().map(|(page_number, entries)| {
1623            let index_reader = index_reader.clone();
1624            async move {
1625                let page = self.lookup_page(page_number, index_reader, metrics).await?;
1626                let needles: Vec<OrderableScalarValue> =
1627                    entries.iter().map(|(_, ov)| ov.clone()).collect();
1628                let present = page.contains_values(&needles)?;
1629                Result::Ok((entries, present))
1630            }
1631        });
1632
1633        let mut result = vec![false; keys.len()];
1634        let page_results: Vec<_> = stream::iter(page_tasks)
1635            .buffer_unordered(get_num_compute_intensive_cpus())
1636            .try_collect()
1637            .await?;
1638        for (entries, present) in page_results {
1639            for (idx, ov) in entries {
1640                if present.contains(&ov) {
1641                    result[idx] = true;
1642                }
1643            }
1644        }
1645        Ok(result)
1646    }
1647
1648    async fn lookup_page(
1649        &self,
1650        page_number: u32,
1651        index_reader: LazyIndexReader,
1652        metrics: &dyn MetricsCollector,
1653    ) -> Result<Arc<FlatIndex>> {
1654        let result = self
1655            .index_cache
1656            .get_or_insert_with_key_hit(BTreePageKey { page_number }, move || async move {
1657                self.read_page(page_number, index_reader, metrics).await
1658            })
1659            .await;
1660        match &result {
1661            Ok((_, true)) => metrics.record_index_cache_hit(),
1662            _ => metrics.record_index_cache_miss(),
1663        }
1664        result.map(|(page, _)| page)
1665    }
1666
1667    #[instrument(level = "debug", skip_all)]
1668    async fn read_page(
1669        &self,
1670        page_number: u32,
1671        index_reader: LazyIndexReader,
1672        metrics: &dyn MetricsCollector,
1673    ) -> Result<FlatIndex> {
1674        metrics.record_part_load();
1675        info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="btree", part_id=page_number);
1676        let index_reader = index_reader.get().await?;
1677        let mut serialized_page = index_reader
1678            .read_record_batch(page_number as u64, self.batch_size)
1679            .await?;
1680        if let Some(frag_reuse_index_ref) = self.frag_reuse_index.as_ref() {
1681            serialized_page =
1682                frag_reuse_index_ref.remap_row_ids_record_batch(serialized_page, 1)?;
1683        }
1684        FlatIndex::try_new(serialized_page)
1685    }
1686
1687    /// Compile a sargable predicate into a physical expr against the per-page
1688    /// schema ([values, ids]). Built once in `search` and shared across pages so
1689    /// a large IN-list is not re-materialized for every page.
1690    fn compile_predicate(&self, query: &SargableQuery) -> Result<Arc<dyn PhysicalExpr>> {
1691        let schema = Arc::new(Schema::new(vec![
1692            Field::new(BTREE_VALUES_COLUMN, self.data_type.clone(), true),
1693            Field::new(BTREE_IDS_COLUMN, DataType::UInt64, false),
1694        ]));
1695        let df_schema = DFSchema::try_from(schema)?;
1696        Ok(create_physical_expr(
1697            &query.to_expr(BTREE_VALUES_COLUMN.to_string()),
1698            &df_schema,
1699            &ExecutionProps::default(),
1700        )?)
1701    }
1702
1703    async fn search_page(
1704        &self,
1705        query: &SargableQuery,
1706        matches: Matches,
1707        index_reader: LazyIndexReader,
1708        prebuilt: Option<&Arc<dyn PhysicalExpr>>,
1709        metrics: &dyn MetricsCollector,
1710    ) -> Result<NullableRowAddrSet> {
1711        let subindex = self
1712            .lookup_page(matches.page_id(), index_reader, metrics)
1713            .await?;
1714
1715        match matches {
1716            // For a large IsIn the predicate is compiled once (see `search`) and
1717            // reused here, instead of rebuilding the whole IN-list per page.
1718            Matches::Some(_) => match prebuilt {
1719                Some(expr) => subindex.search_prebuilt(expr, metrics),
1720                None => subindex.search(query, metrics),
1721            },
1722            Matches::All(_) => Ok(match query {
1723                // This means we hit an all-null page so just grab all row ids as true
1724                SargableQuery::IsNull() => subindex.all_ignore_nulls(),
1725                _ => subindex.all(),
1726            }),
1727        }
1728    }
1729
1730    #[instrument(level = "debug", skip_all)]
1731    fn try_from_serialized(
1732        data: RecordBatch,
1733        store: Arc<dyn IndexStore>,
1734        index_cache: &LanceCache,
1735        batch_size: u64,
1736        ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1737        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1738    ) -> Result<Self> {
1739        let data_type = data.column(0).data_type().clone();
1740        let page_lookup = Arc::new(BTreeLookup::try_new(data)?);
1741
1742        Ok(Self::new(
1743            page_lookup,
1744            store,
1745            data_type,
1746            WeakLanceCache::from(index_cache),
1747            batch_size,
1748            ranges_to_files,
1749            frag_reuse_index,
1750        ))
1751    }
1752
1753    async fn load(
1754        store: Arc<dyn IndexStore>,
1755        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1756        index_cache: &LanceCache,
1757    ) -> Result<Arc<Self>> {
1758        let (page_lookup_file, standalone_partition_page_file) =
1759            match store.open_index_file(BTREE_LOOKUP_NAME).await {
1760                Ok(page_lookup_file) => (page_lookup_file, None),
1761                Err(original_err) if is_missing_lookup_error(&original_err) => {
1762                    let files = store.list_files_with_sizes().await?;
1763                    let Some((lookup_file, page_file)) = find_single_partition_files(&files)?
1764                    else {
1765                        return Err(original_err);
1766                    };
1767                    (
1768                        store.open_index_file(lookup_file).await?,
1769                        Some(page_file.to_string()),
1770                    )
1771                }
1772                Err(other_err) => return Err(other_err),
1773            };
1774        let num_rows_in_lookup = page_lookup_file.num_rows();
1775        let serialized_lookup = page_lookup_file
1776            .read_range(0..num_rows_in_lookup, None)
1777            .await?;
1778        let file_schema = page_lookup_file.schema();
1779        let batch_size = file_schema
1780            .metadata
1781            .get(BATCH_SIZE_META_KEY)
1782            .map(|bs| bs.parse().unwrap_or(DEFAULT_BTREE_BATCH_SIZE))
1783            .unwrap_or(DEFAULT_BTREE_BATCH_SIZE);
1784
1785        let range_partitioned = file_schema
1786            .metadata
1787            .get(RANGE_PARTITIONED_META_KEY)
1788            .map(|bs| bs.parse().unwrap_or(DEFAULT_RANGE_PARTITIONED))
1789            .unwrap_or(DEFAULT_RANGE_PARTITIONED);
1790        // For range-partitioned indices, construct the `ranges_to_files` map.
1791        // This converts the list of (partition ID, page count) from metadata into a map
1792        // from a global page range to its corresponding file and starting offset.
1793        let ranges_to_files = if let Some(page_file_name) = standalone_partition_page_file {
1794            let page_numbers = serialized_lookup
1795                .column(3)
1796                .as_any()
1797                .downcast_ref::<UInt32Array>()
1798                .unwrap();
1799            let max_page_number = page_numbers.values().iter().copied().max().unwrap_or(0);
1800            let mut range_map = RangeInclusiveMap::new();
1801            range_map.insert(0..=max_page_number, (page_file_name, 0));
1802            Some(Arc::new(range_map))
1803        } else if range_partitioned {
1804            let part_sizes_str = file_schema
1805            .metadata
1806            .get(PAGE_NUM_PER_RANGE_PARTITION_META_KEY)
1807            .expect("Range-partitioned Btree lookup file must have page-number-per-range-file metadata!");
1808            let part_sizes_vec: Vec<(u64, u32)> = serde_json::from_str(part_sizes_str)?;
1809            let mut offset: u32 = 0;
1810
1811            let range_map = part_sizes_vec
1812                .into_iter()
1813                .map(|(id, size)| {
1814                    let range = offset..=(offset + size - 1);
1815                    let file_with_size = (part_page_data_file_path(id), offset);
1816                    offset += size;
1817                    (range, file_with_size)
1818                })
1819                .collect();
1820
1821            Some(Arc::new(range_map))
1822        } else {
1823            None
1824        };
1825
1826        Ok(Arc::new(Self::try_from_serialized(
1827            serialized_lookup,
1828            store,
1829            index_cache,
1830            batch_size,
1831            ranges_to_files,
1832            frag_reuse_index,
1833        )?))
1834    }
1835
1836    // For legacy reasons a btree index expects the training input to use value/_rowid
1837    fn train_schema(&self) -> Schema {
1838        let value_field = Field::new(VALUE_COLUMN_NAME, self.data_type.clone(), true);
1839        let row_id_field = Field::new(ROW_ID, DataType::UInt64, false);
1840        Schema::new(vec![value_field, row_id_field])
1841    }
1842
1843    /// Create a stream of all the data in the index, in the same format used to train the index
1844    async fn data_stream(&self) -> Result<SendableRecordBatchStream> {
1845        let lazy_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
1846        let reader = lazy_reader.get().await?;
1847        let new_schema = Arc::new(self.train_schema());
1848        let new_schema_clone = new_schema.clone();
1849        let reader_stream = IndexReaderStream::new(reader, self.batch_size).await;
1850        let batches = reader_stream
1851            .map(|fut| fut.map_err(DataFusionError::from))
1852            .buffered(self.store.io_parallelism())
1853            .map_ok(move |batch| {
1854                RecordBatch::try_new(
1855                    new_schema.clone(),
1856                    vec![batch.column(0).clone(), batch.column(1).clone()],
1857                )
1858                .unwrap()
1859            })
1860            .boxed();
1861        Ok(Box::pin(RecordBatchStreamAdapter::new(
1862            new_schema_clone,
1863            batches,
1864        )))
1865    }
1866
1867    /// Merge N source BTree segments plus an additional `new_data` stream into
1868    /// a single BTree under `dest_store`, without re-reading the dataset.
1869    pub async fn merge_segments(
1870        segments: &[Arc<Self>],
1871        new_data: SendableRecordBatchStream,
1872        dest_store: &dyn IndexStore,
1873        old_data_filters: &[Option<OldIndexDataFilter>],
1874    ) -> Result<CreatedIndex> {
1875        let Some(first) = segments.first() else {
1876            return Err(Error::invalid_input(
1877                "cannot merge BTree index without at least one source segment".to_string(),
1878            ));
1879        };
1880
1881        if old_data_filters.len() != segments.len() {
1882            return Err(Error::invalid_input(format!(
1883                "BTree merge: expected one old-data filter per source segment \
1884                 (segments={}, filters={})",
1885                segments.len(),
1886                old_data_filters.len()
1887            )));
1888        }
1889
1890        for segment in segments.iter().skip(1) {
1891            if segment.data_type != first.data_type {
1892                return Err(Error::index(format!(
1893                    "cannot merge BTree segments with different value types ({:?} vs {:?})",
1894                    first.data_type, segment.data_type
1895                )));
1896            }
1897        }
1898
1899        let new_schema = new_data.schema();
1900        let value_column_index = new_schema.index_of(VALUE_COLUMN_NAME)?;
1901        let new_value_type = new_schema.field(value_column_index).data_type();
1902        if new_value_type != &first.data_type {
1903            return Err(Error::invalid_input(format!(
1904                "BTree merge: new_data value column type {:?} does not match \
1905                 segment value type {:?}",
1906                new_value_type, first.data_type
1907            )));
1908        }
1909
1910        let mut inputs: Vec<Arc<dyn ExecutionPlan>> = Vec::with_capacity(segments.len() + 1);
1911        for (segment, old_data_filter) in segments.iter().zip(old_data_filters) {
1912            if filter_keeps_nothing(old_data_filter) {
1913                continue;
1914            }
1915            let stream = segment.data_stream().await?;
1916            let stream = match segment.frag_reuse_index.clone() {
1917                Some(frag_reuse_index) => remap_row_ids(stream, frag_reuse_index),
1918                None => stream,
1919            };
1920            let stream = match old_data_filter.clone() {
1921                Some(filter) => filter_row_ids(stream, filter),
1922                None => stream,
1923            };
1924            inputs.push(Arc::new(OneShotExec::new(stream)));
1925        }
1926        inputs.push(Arc::new(OneShotExec::new(new_data)));
1927
1928        let sort_expr = PhysicalSortExpr {
1929            expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_index)),
1930            options: SortOptions {
1931                descending: false,
1932                nulls_first: true,
1933            },
1934        };
1935        // UnionExec yields multiple partitions; SortPreservingMergeExec merges
1936        // them back into a single partition while preserving value-ordering.
1937        let unioned = UnionExec::try_new(inputs)?;
1938        let ordered = Arc::new(SortPreservingMergeExec::new([sort_expr].into(), unioned));
1939        let unchunked = execute_plan(
1940            ordered,
1941            LanceExecutionOptions {
1942                use_spilling: true,
1943                ..Default::default()
1944            },
1945        )?;
1946        let merged_stream = chunk_concat_stream(unchunked, first.batch_size as usize);
1947
1948        let files =
1949            train_btree_index(merged_stream, dest_store, first.batch_size, None, None).await?;
1950
1951        Ok(CreatedIndex {
1952            index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
1953                .unwrap(),
1954            index_version: BTREE_INDEX_VERSION,
1955            files,
1956        })
1957    }
1958}
1959
1960/// Filter a stream of record batches using the selection semantics encapsulated
1961/// by `old_data_filter`.
1962fn filter_row_ids(
1963    stream: SendableRecordBatchStream,
1964    old_data_filter: OldIndexDataFilter,
1965) -> SendableRecordBatchStream {
1966    let schema = stream.schema();
1967    let filtered = stream.map(move |batch_result| {
1968        let batch = batch_result?;
1969        let row_ids = batch[ROW_ID]
1970            .as_any()
1971            .downcast_ref::<arrow_array::UInt64Array>()
1972            .ok_or_else(|| Error::internal("expected UInt64Array for row_id column"))?;
1973        let mask = old_data_filter.filter_row_ids(row_ids);
1974        Ok(arrow_select::filter::filter_record_batch(&batch, &mask)?)
1975    });
1976    Box::pin(RecordBatchStreamAdapter::new(schema, filtered))
1977}
1978
1979/// True if `filter` would keep no rows at all (its keep-set is empty), letting
1980/// the merge skip reading the segment entirely.
1981fn filter_keeps_nothing(filter: &Option<OldIndexDataFilter>) -> bool {
1982    match filter {
1983        Some(OldIndexDataFilter::Fragments { to_keep, .. }) => to_keep.is_empty(),
1984        Some(OldIndexDataFilter::RowIds(valid)) => valid.is_empty(),
1985        None => false,
1986    }
1987}
1988
1989fn remap_row_ids(
1990    stream: SendableRecordBatchStream,
1991    frag_reuse_index: Arc<dyn RowIdRemapper>,
1992) -> SendableRecordBatchStream {
1993    let schema = stream.schema();
1994    let remapped = stream.map(move |batch_result| {
1995        let batch = batch_result?;
1996        Ok(frag_reuse_index.remap_row_ids_record_batch(batch, 1)?)
1997    });
1998    Box::pin(RecordBatchStreamAdapter::new(schema, remapped))
1999}
2000
2001fn wrap_bound(bound: &Bound<ScalarValue>) -> Bound<OrderableScalarValue> {
2002    match bound {
2003        Bound::Unbounded => Bound::Unbounded,
2004        Bound::Included(val) => Bound::Included(OrderableScalarValue(val.clone())),
2005        Bound::Excluded(val) => Bound::Excluded(OrderableScalarValue(val.clone())),
2006    }
2007}
2008
2009fn serialize_with_display<T: Display, S: Serializer>(
2010    value: &Option<T>,
2011    serializer: S,
2012) -> std::result::Result<S::Ok, S::Error> {
2013    if let Some(value) = value {
2014        serializer.collect_str(value)
2015    } else {
2016        serializer.collect_str("N/A")
2017    }
2018}
2019
2020#[derive(Serialize)]
2021struct BTreeStatistics {
2022    #[serde(serialize_with = "serialize_with_display")]
2023    min: Option<OrderableScalarValue>,
2024    #[serde(serialize_with = "serialize_with_display")]
2025    max: Option<OrderableScalarValue>,
2026    num_pages: u32,
2027}
2028
2029#[async_trait]
2030impl Index for BTreeIndex {
2031    fn as_any(&self) -> &dyn Any {
2032        self
2033    }
2034
2035    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
2036        self
2037    }
2038
2039    async fn prewarm(&self) -> Result<()> {
2040        let index_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
2041        let reader = index_reader.get().await?;
2042        let num_pages = reader.num_batches(self.batch_size).await;
2043        let mut pages = stream::iter(0..num_pages)
2044            .map(|page_idx| {
2045                let index_reader = index_reader.clone();
2046                async move {
2047                    let page = self
2048                        .read_page(page_idx, index_reader, &NoOpMetricsCollector)
2049                        .await?;
2050                    Result::Ok((page_idx, page))
2051                }
2052            })
2053            .buffer_unordered(get_num_compute_intensive_cpus());
2054
2055        while let Some((page_idx, page)) = pages.try_next().await? {
2056            let inserted = self
2057                .index_cache
2058                .insert_with_key(
2059                    &BTreePageKey {
2060                        page_number: page_idx,
2061                    },
2062                    Arc::new(page),
2063                )
2064                .await;
2065
2066            if !inserted {
2067                return Err(Error::internal(
2068                    "Failed to prewarm index: cache is no longer available".to_string(),
2069                ));
2070            }
2071        }
2072
2073        Ok(())
2074    }
2075
2076    fn index_type(&self) -> IndexType {
2077        IndexType::BTree
2078    }
2079
2080    fn statistics(&self) -> Result<serde_json::Value> {
2081        let lookup = &self.page_lookup;
2082        let batch = &lookup.batch;
2083        let num_rows = batch.num_rows();
2084        // The batch is sorted by `min`, so the smallest searchable value is the
2085        // `min` of the first non-all-null page and the largest is the `max` of the
2086        // last page.
2087        let (min, max) = if lookup.search_start >= num_rows {
2088            (None, None)
2089        } else {
2090            let min = OrderableScalarValue(ScalarValue::try_from_array(
2091                batch.column(0),
2092                lookup.search_start,
2093            )?);
2094            let max =
2095                OrderableScalarValue(ScalarValue::try_from_array(batch.column(1), num_rows - 1)?);
2096            (Some(min), Some(max))
2097        };
2098        serde_json::to_value(&BTreeStatistics {
2099            num_pages: num_rows as u32,
2100            min,
2101            max,
2102        })
2103        .map_err(|err| err.into())
2104    }
2105
2106    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
2107        let mut frag_ids = RoaringBitmap::default();
2108
2109        let lazy_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
2110        let sub_index_reader = lazy_reader.get().await?;
2111        let mut reader_stream = IndexReaderStream::new(sub_index_reader, self.batch_size)
2112            .await
2113            .buffered(self.store.io_parallelism());
2114        while let Some(serialized) = reader_stream.try_next().await? {
2115            let page = FlatIndex::try_new(serialized)?;
2116            frag_ids |= page.calculate_included_frags()?;
2117        }
2118
2119        Ok(frag_ids)
2120    }
2121}
2122
2123#[async_trait]
2124impl ScalarIndex for BTreeIndex {
2125    async fn search(
2126        &self,
2127        query: &dyn AnyQuery,
2128        metrics: &dyn MetricsCollector,
2129    ) -> Result<SearchResult> {
2130        let query = query.as_any().downcast_ref::<SargableQuery>().unwrap();
2131        let mut pages = match query {
2132            SargableQuery::Equals(val) => self
2133                .page_lookup
2134                .pages_eq(&OrderableScalarValue(val.clone())),
2135            SargableQuery::Range(start, end) => self
2136                .page_lookup
2137                .pages_between((wrap_bound(start).as_ref(), wrap_bound(end).as_ref())),
2138            SargableQuery::IsIn(values) => self
2139                .page_lookup
2140                .pages_in(values.iter().map(|val| OrderableScalarValue(val.clone()))),
2141            SargableQuery::FullTextSearch(_) => {
2142                return Err(Error::invalid_input(
2143                    "full text search is not supported for BTree index, build a inverted index for it",
2144                ));
2145            }
2146            SargableQuery::IsNull() => Ok(self.page_lookup.pages_null()),
2147            SargableQuery::LikePrefix(prefix) => {
2148                // Convert LikePrefix to a range query: [prefix, next_prefix)
2149                match prefix {
2150                    ScalarValue::Utf8(Some(s)) => {
2151                        let start = Bound::Included(OrderableScalarValue(prefix.clone()));
2152                        let end = match compute_next_prefix(s) {
2153                            Some(next) => {
2154                                Bound::Excluded(OrderableScalarValue(ScalarValue::Utf8(Some(next))))
2155                            }
2156                            None => Bound::Unbounded,
2157                        };
2158                        self.page_lookup
2159                            .pages_between((start.as_ref(), end.as_ref()))
2160                    }
2161                    ScalarValue::LargeUtf8(Some(s)) => {
2162                        let start = Bound::Included(OrderableScalarValue(prefix.clone()));
2163                        let end = match compute_next_prefix(s) {
2164                            Some(next) => Bound::Excluded(OrderableScalarValue(
2165                                ScalarValue::LargeUtf8(Some(next)),
2166                            )),
2167                            None => Bound::Unbounded,
2168                        };
2169                        self.page_lookup
2170                            .pages_between((start.as_ref(), end.as_ref()))
2171                    }
2172                    _ => {
2173                        // Conservative: return all pages for non-string types
2174                        // This is consistent with ZoneMap behavior
2175                        self.page_lookup
2176                            .pages_between((Bound::Unbounded, Bound::Unbounded))
2177                    }
2178                }
2179            }
2180        }?;
2181
2182        // For non-IsNull queries, also include null pages so that null row IDs
2183        // are tracked in the result. Any comparison with NULL yields NULL, and
2184        // we need this information for correct three-valued logic (e.g. NOT,
2185        // OR). Without this, a query like `NOT(x = 0)` on data where 0 doesn't
2186        // exist would incorrectly include NULL rows.
2187        //
2188        // We add them as Matches::Some (not Matches::All) so that
2189        // FlatIndex::search() evaluates the predicate and correctly marks
2190        // the rows as NULL rather than TRUE.
2191        //
2192        // TODO: the lookup batch retains a per-page `null_count`. A fully-covered
2193        // page with zero nulls is a true Matches::All, while one with nulls needs
2194        // Matches::Some only to track the null rows; surfacing `null_count` here
2195        // could refine that classification (see #6802).
2196        if !matches!(query, SargableQuery::IsNull()) {
2197            let existing: HashSet<u32> = pages.iter().map(|m| m.page_id()).collect();
2198            for &page_id in self
2199                .page_lookup
2200                .null_pages
2201                .iter()
2202                .chain(self.page_lookup.all_null_pages.iter())
2203            {
2204                if !existing.contains(&page_id) {
2205                    pages.push(Matches::Some(page_id));
2206                }
2207            }
2208        }
2209
2210        // Compile a large IsIn predicate once and reuse it across every page;
2211        // rebuilding the full IN-list per page is O(pages * values) and dominates
2212        // the lookup for sets with many values.
2213        let prebuilt = match query {
2214            SargableQuery::IsIn(_) => Some(self.compile_predicate(query)?),
2215            _ => None,
2216        };
2217
2218        let lazy_index_reader =
2219            LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
2220        let page_tasks = pages
2221            .into_iter()
2222            .map(|page_index| {
2223                self.search_page(
2224                    query,
2225                    page_index,
2226                    lazy_index_reader.clone(),
2227                    prebuilt.as_ref(),
2228                    metrics,
2229                )
2230                .boxed()
2231            })
2232            .collect::<Vec<_>>();
2233        debug!("Searching {} btree pages", page_tasks.len());
2234
2235        // Collect both matching row IDs and null row IDs from all pages
2236        let results: Vec<NullableRowAddrSet> = stream::iter(page_tasks)
2237            // I/O and compute mixed here but important case is index in cache so
2238            // use compute intensive thread count
2239            .buffered(get_num_compute_intensive_cpus())
2240            .try_collect()
2241            .await?;
2242
2243        // Merge matching row IDs
2244        let selection = NullableRowAddrSet::union_all(&results);
2245
2246        Ok(SearchResult::Exact(selection))
2247    }
2248
2249    fn can_remap(&self) -> bool {
2250        true
2251    }
2252
2253    async fn remap(
2254        &self,
2255        mapping: &RowAddrRemap,
2256        dest_store: &dyn IndexStore,
2257    ) -> Result<CreatedIndex> {
2258        // (part_id, path)
2259        // The part_id is None for a basic index
2260        // For a range-based index we use Some(0), Some(1), ...
2261        //   even if those weren't the original part ids
2262        let part_page_files: Vec<(Option<u32>, &str)> =
2263            if let Some(ranges_to_files) = &self.ranges_to_files {
2264                // Range-based Index: Directly collect references to the file paths.
2265                ranges_to_files
2266                    .iter()
2267                    .enumerate()
2268                    .map(|(part_id, (_, (path, _)))| (Some(part_id as u32), path.as_str()))
2269                    .collect()
2270            } else {
2271                // Basic Index: There is only one source page file.
2272                vec![(None, BTREE_PAGES_NAME)]
2273            };
2274
2275        let mapping = Arc::new(mapping.clone());
2276        let train_schema = Arc::new(self.train_schema());
2277        let mut remapped_files = Vec::new();
2278
2279        // TODO: Could potentially parallelize this across parts, unclear it would be worth it
2280        for (part_id, page_file) in part_page_files {
2281            // Retrain on the remapped pages
2282            let sub_index_reader = self.store.open_index_file(page_file).await?;
2283            let mapping = mapping.clone();
2284
2285            let train_schema_clone = train_schema.clone();
2286            let train_schema = train_schema.clone();
2287
2288            let remapped_stream = IndexReaderStream::new(sub_index_reader, self.batch_size)
2289                .await
2290                .buffered(self.store.io_parallelism())
2291                .map_err(DataFusionError::from)
2292                .and_then(move |batch| {
2293                    // Remap the batch and then convert from the serialized schema to the training input schema
2294                    let remapped =
2295                        FlatIndex::remap_batch(batch, &mapping).map_err(DataFusionError::from);
2296                    let with_train_schema = remapped.and_then(|batch| {
2297                        RecordBatch::try_new(train_schema.clone(), batch.columns().to_vec())
2298                            .map_err(DataFusionError::from)
2299                    });
2300                    std::future::ready(with_train_schema)
2301                });
2302
2303            let remapped_stream = Box::pin(RecordBatchStreamAdapter::new(
2304                train_schema_clone,
2305                remapped_stream,
2306            ));
2307
2308            let mut files =
2309                train_btree_index(remapped_stream, dest_store, self.batch_size, None, part_id)
2310                    .await?;
2311            remapped_files.append(&mut files);
2312        }
2313
2314        if let Some(ranges_to_files) = &self.ranges_to_files {
2315            let num_parts = ranges_to_files.len();
2316            // Merge the lookups if we are a range-based index
2317            let page_files = (0..num_parts)
2318                .map(|part_id| part_page_data_file_path((part_id as u64) << 32))
2319                .collect::<Vec<_>>();
2320            let lookup_files = (0..num_parts)
2321                .map(|part_id| part_lookup_file_path((part_id as u64) << 32))
2322                .collect::<Vec<_>>();
2323            let merged_files = merge_metadata_files(
2324                dest_store,
2325                &page_files,
2326                &lookup_files,
2327                None,
2328                noop_progress(),
2329            )
2330            .await?;
2331            remapped_files.retain(|file| file.path.ends_with("_page_data.lance"));
2332            remapped_files.extend(merged_files);
2333        }
2334
2335        Ok(CreatedIndex {
2336            index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
2337                .unwrap(),
2338            index_version: BTREE_INDEX_VERSION,
2339            files: remapped_files,
2340        })
2341    }
2342
2343    async fn update(
2344        &self,
2345        new_data: SendableRecordBatchStream,
2346        dest_store: &dyn IndexStore,
2347        old_data_filter: Option<OldIndexDataFilter>,
2348    ) -> Result<CreatedIndex> {
2349        // Updating is the single-segment case of a segment merge: union this
2350        // index's data with `new_data`, re-sort on value, and retrain.
2351        Self::merge_segments(
2352            &[Arc::new(self.clone())],
2353            new_data,
2354            dest_store,
2355            &[old_data_filter],
2356        )
2357        .await
2358    }
2359
2360    fn update_criteria(&self) -> UpdateCriteria {
2361        UpdateCriteria::only_new_data(TrainingCriteria::new(TrainingOrdering::Values).with_row_id())
2362    }
2363
2364    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
2365        let params = serde_json::to_value(BTreeParameters {
2366            zone_size: Some(self.batch_size),
2367            range_id: None,
2368        })?;
2369        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::BTree).with_params(&params))
2370    }
2371}
2372
2373struct BatchStats {
2374    min: ScalarValue,
2375    max: ScalarValue,
2376    null_count: u32,
2377}
2378
2379fn analyze_batch(batch: &RecordBatch) -> Result<BatchStats> {
2380    let values = batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?;
2381    if values.is_empty() {
2382        return Err(Error::internal(
2383            "received an empty batch in btree training".to_string(),
2384        ));
2385    }
2386    let min = ScalarValue::try_from_array(&values, 0)
2387        .map_err(|e| Error::internal(format!("failed to get min value from batch: {}", e)))?;
2388    let max = ScalarValue::try_from_array(&values, values.len() - 1)
2389        .map_err(|e| Error::internal(format!("failed to get max value from batch: {}", e)))?;
2390
2391    Ok(BatchStats {
2392        min,
2393        max,
2394        null_count: values.null_count() as u32,
2395    })
2396}
2397
2398/// A trait that must be implemented by anything that wishes to act as a btree subindex
2399#[async_trait]
2400pub trait BTreeSubIndex: Debug + Send + Sync + DeepSizeOf {
2401    /// Trains the subindex on a single batch of data and serializes it to Arrow
2402    async fn train(&self, batch: RecordBatch) -> Result<RecordBatch>;
2403
2404    /// Deserialize a subindex from Arrow
2405    async fn load_subindex(&self, serialized: RecordBatch) -> Result<Arc<dyn ScalarIndex>>;
2406
2407    /// Retrieve the data used to originally train this page
2408    ///
2409    /// In order to perform an update we need to merge the old data in with the new data which
2410    /// means we need to access the new data.  Right now this is convenient for flat indices but
2411    /// we may need to take a different approach if we ever decide to use a sub-index other than
2412    /// flat
2413    async fn retrieve_data(&self, serialized: RecordBatch) -> Result<RecordBatch>;
2414
2415    /// The schema of the subindex when serialized to Arrow
2416    fn schema(&self) -> &Arc<Schema>;
2417
2418    /// Given a serialized page, deserialize it, remap the row ids, and re-serialize it
2419    async fn remap_subindex(
2420        &self,
2421        serialized: RecordBatch,
2422        mapping: &RowAddrRemap,
2423    ) -> Result<RecordBatch>;
2424}
2425
2426struct EncodedBatch {
2427    stats: BatchStats,
2428    page_number: u32,
2429}
2430
2431async fn train_btree_page(
2432    batch: RecordBatch,
2433    batch_idx: u32,
2434    writer: &mut dyn IndexWriter,
2435    schema: Arc<Schema>,
2436) -> Result<EncodedBatch> {
2437    let stats = analyze_batch(&batch)?;
2438
2439    // Renames from value/_rowid to values/ids
2440    let trained = RecordBatch::try_new(
2441        schema.clone(),
2442        vec![
2443            batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?.clone(),
2444            batch.column_by_name(ROW_ID).expect_ok()?.clone(),
2445        ],
2446    )?;
2447
2448    writer.write_record_batch(trained).await?;
2449    Ok(EncodedBatch {
2450        stats,
2451        page_number: batch_idx,
2452    })
2453}
2454
2455fn btree_stats_as_batch(stats: Vec<EncodedBatch>, value_type: &DataType) -> Result<RecordBatch> {
2456    let mins = if stats.is_empty() {
2457        new_empty_array(value_type)
2458    } else {
2459        ScalarValue::iter_to_array(stats.iter().map(|stat| stat.stats.min.clone()))?
2460    };
2461    let maxs = if stats.is_empty() {
2462        new_empty_array(value_type)
2463    } else {
2464        ScalarValue::iter_to_array(stats.iter().map(|stat| stat.stats.max.clone()))?
2465    };
2466    let null_counts = UInt32Array::from_iter_values(stats.iter().map(|stat| stat.stats.null_count));
2467    let page_numbers = UInt32Array::from_iter_values(stats.iter().map(|stat| stat.page_number));
2468
2469    let schema = Arc::new(Schema::new(vec![
2470        // min and max can be null if the entire batch is null values
2471        Field::new("min", mins.data_type().clone(), true),
2472        Field::new("max", maxs.data_type().clone(), true),
2473        Field::new("null_count", null_counts.data_type().clone(), false),
2474        Field::new("page_idx", page_numbers.data_type().clone(), false),
2475    ]));
2476
2477    let columns = vec![
2478        mins,
2479        maxs,
2480        Arc::new(null_counts) as Arc<dyn Array>,
2481        Arc::new(page_numbers) as Arc<dyn Array>,
2482    ];
2483
2484    Ok(RecordBatch::try_new(schema, columns)?)
2485}
2486
2487/// Train a btree index from a stream of sorted page-size batches of values and row ids
2488pub async fn train_btree_index(
2489    batches_source: SendableRecordBatchStream,
2490    index_store: &dyn IndexStore,
2491    batch_size: u64,
2492    fragment_ids: Option<Vec<u32>>,
2493    range_id: Option<u32>,
2494) -> Result<Vec<IndexFile>> {
2495    // Create `partition_id` for distributed index building.
2496    // This ID serves as a high-level mask (first 32 bits of a u64) to ensure
2497    // that index partitions generated by different workers do not conflict.
2498    // Lance supports two strategies for distributed training: fragment-based and range-based.
2499    let partition_id = fragment_ids
2500        .as_ref()
2501        // --- Fragment-based Partitioning ---
2502        // Used when training sub-indexes on a fragment-level-split basis. The `partition_id` is
2503        // derived from `fragment_ids` to associate the index pages with their source fragment.
2504        .and_then(|frag_ids| frag_ids.first())
2505        .map(|&first_frag_id| (first_frag_id as u64) << 32)
2506        // --- Range-based Partitioning ---
2507        // Built upon data globally sorted by an external compute engine. The `range_id` creates
2508        // a unique name for the index pages generated by each worker.
2509        .or_else(|| range_id.map(|id| (id as u64) << 32));
2510
2511    let flat_schema = Arc::new(Schema::new(vec![
2512        Field::new(
2513            BTREE_VALUES_COLUMN,
2514            batches_source.schema().field(0).data_type().clone(),
2515            true,
2516        ),
2517        Field::new(BTREE_IDS_COLUMN, DataType::UInt64, false),
2518    ]));
2519
2520    let mut sub_index_file = match partition_id {
2521        None => {
2522            index_store
2523                .new_index_file(BTREE_PAGES_NAME, flat_schema.clone())
2524                .await?
2525        }
2526        Some(partition_id) => {
2527            index_store
2528                .new_index_file(
2529                    part_page_data_file_path(partition_id).as_str(),
2530                    flat_schema.clone(),
2531                )
2532                .await?
2533        }
2534    };
2535
2536    let mut encoded_batches = Vec::new();
2537    let mut batch_idx = 0;
2538
2539    let value_type = batches_source
2540        .schema()
2541        .field_with_name(VALUE_COLUMN_NAME)?
2542        .data_type()
2543        .clone();
2544
2545    let mut batches_source = chunk_concat_stream(batches_source, batch_size as usize);
2546
2547    while let Some(batch) = batches_source.try_next().await? {
2548        encoded_batches.push(
2549            train_btree_page(
2550                batch,
2551                batch_idx,
2552                sub_index_file.as_mut(),
2553                flat_schema.clone(),
2554            )
2555            .await?,
2556        );
2557        batch_idx += 1;
2558    }
2559    let pages_file = sub_index_file.finish().await?;
2560    let record_batch = btree_stats_as_batch(encoded_batches, &value_type)?;
2561    let mut file_schema = record_batch.schema().as_ref().clone();
2562    file_schema
2563        .metadata
2564        .insert(BATCH_SIZE_META_KEY.to_string(), batch_size.to_string());
2565    file_schema.metadata.insert(
2566        RANGE_PARTITIONED_META_KEY.to_string(),
2567        range_id.is_some().to_string(),
2568    );
2569    let mut btree_index_file = match partition_id {
2570        None => {
2571            index_store
2572                .new_index_file(BTREE_LOOKUP_NAME, Arc::new(file_schema))
2573                .await?
2574        }
2575        Some(partition_id) => {
2576            index_store
2577                .new_index_file(
2578                    part_lookup_file_path(partition_id).as_str(),
2579                    Arc::new(file_schema),
2580                )
2581                .await?
2582        }
2583    };
2584    btree_index_file.write_record_batch(record_batch).await?;
2585    let lookup_file = btree_index_file.finish().await?;
2586    Ok(vec![pages_file, lookup_file])
2587}
2588
2589fn find_single_partition_files(files: &[super::IndexFile]) -> Result<Option<(&str, &str)>> {
2590    let lookup_files = files
2591        .iter()
2592        .filter_map(|file| {
2593            (file.path.starts_with("part_") && file.path.ends_with("_page_lookup.lance"))
2594                .then_some(file.path.as_str())
2595        })
2596        .collect::<Vec<_>>();
2597    let page_files = files
2598        .iter()
2599        .filter_map(|file| {
2600            (file.path.starts_with("part_") && file.path.ends_with("_page_data.lance"))
2601                .then_some(file.path.as_str())
2602        })
2603        .collect::<Vec<_>>();
2604
2605    if lookup_files.len() != 1 || page_files.len() != 1 {
2606        return Ok(None);
2607    }
2608
2609    let lookup_partition_id = extract_partition_id(lookup_files[0])?;
2610    let page_partition_id = extract_partition_id(page_files[0])?;
2611    if lookup_partition_id != page_partition_id {
2612        return Ok(None);
2613    }
2614
2615    Ok(Some((lookup_files[0], page_files[0])))
2616}
2617
2618fn is_missing_lookup_error(err: &Error) -> bool {
2619    matches!(err, Error::NotFound { .. })
2620        || matches!(
2621            err,
2622            Error::IO { source, .. }
2623                if source
2624                    .downcast_ref::<ObjectStoreError>()
2625                    .map(|os_err| matches!(os_err, ObjectStoreError::NotFound { .. }))
2626                    .unwrap_or(false)
2627        )
2628}
2629
2630/// Merge multiple partition page / lookup files into a complete metadata file
2631///
2632/// In a distributed environment, each worker node writes partition page / lookup file for the partitions it processes,
2633/// and this function merges these files into a final metadata file.
2634/// - For fragment-based indices, it performs a full K-way sort-merge of page files to create new global page and lookup files.
2635/// - For range-based indices, it concatenates lookup files, as data is already globally sorted.
2636async fn merge_metadata_files(
2637    store: &dyn IndexStore,
2638    part_page_files: &[String],
2639    part_lookup_files: &[String],
2640    batch_readhead: Option<usize>,
2641    progress: Arc<dyn IndexBuildProgress>,
2642) -> Result<Vec<IndexFile>> {
2643    if part_lookup_files.is_empty() || part_page_files.is_empty() {
2644        return Err(Error::internal(
2645            "No partition files provided for merging".to_string(),
2646        ));
2647    }
2648
2649    // Step 1: Create lookup map for page files by partition ID
2650    if part_lookup_files.len() != part_page_files.len() {
2651        return Err(Error::internal(format!(
2652            "Number of partition lookup files ({}) does not match number of partition page files ({})",
2653            part_lookup_files.len(),
2654            part_page_files.len()
2655        )));
2656    }
2657    let mut page_files_map = HashMap::new();
2658    for page_file in part_page_files {
2659        let partition_id = extract_partition_id(page_file)?;
2660        page_files_map.insert(partition_id, page_file);
2661    }
2662
2663    // Step 2: Validate that all lookup files have corresponding page files
2664    for lookup_file in part_lookup_files {
2665        let partition_id = extract_partition_id(lookup_file)?;
2666        if !page_files_map.contains_key(&partition_id) {
2667            return Err(Error::internal(format!(
2668                "No corresponding page file found for lookup file: {} (partition_id: {})",
2669                lookup_file, partition_id
2670            )));
2671        }
2672    }
2673
2674    // Step 3: Extract shared metadata and generate lookup_schema
2675    let first_lookup_reader = store.open_index_file(&part_lookup_files[0]).await?;
2676    let batch_size = first_lookup_reader
2677        .schema()
2678        .metadata
2679        .get(BATCH_SIZE_META_KEY)
2680        .map(|bs| bs.parse().unwrap_or(DEFAULT_BTREE_BATCH_SIZE))
2681        .unwrap_or(DEFAULT_BTREE_BATCH_SIZE);
2682    let range_partitioned = first_lookup_reader
2683        .schema()
2684        .metadata
2685        .get(RANGE_PARTITIONED_META_KEY)
2686        .map(|bs| bs.parse().unwrap_or(DEFAULT_RANGE_PARTITIONED))
2687        .unwrap_or(DEFAULT_RANGE_PARTITIONED);
2688
2689    // Get the value type from lookup schema (min column)
2690    let value_type = first_lookup_reader
2691        .schema()
2692        .fields
2693        .first()
2694        .unwrap()
2695        .data_type();
2696
2697    let mut metadata = HashMap::new();
2698    metadata.insert(BATCH_SIZE_META_KEY.to_string(), batch_size.to_string());
2699    let lookup_schema = Arc::new(Schema::new(vec![
2700        Field::new("min", value_type.clone(), true),
2701        Field::new("max", value_type.clone(), true),
2702        Field::new("null_count", DataType::UInt32, false),
2703        Field::new("page_idx", DataType::UInt32, false),
2704    ]));
2705
2706    // Step 4: Merge pages and lookups and generate new index files
2707    if range_partitioned {
2708        merge_range_partitioned_lookups(
2709            store,
2710            part_lookup_files,
2711            lookup_schema,
2712            metadata,
2713            batch_size,
2714            batch_readhead,
2715            progress,
2716        )
2717        .await
2718        .map(|file| vec![file])
2719    } else {
2720        merge_pages_and_lookups(
2721            store,
2722            part_page_files,
2723            part_lookup_files,
2724            &page_files_map,
2725            lookup_schema,
2726            metadata,
2727            batch_size,
2728            batch_readhead,
2729            progress,
2730        )
2731        .await
2732    }
2733}
2734
2735/// Merges multiple lookup files from a range-partitioned index into a single, unified lookup file.
2736///
2737/// A range-partitioned B-Tree index creates a separate `page_lookup.lance` file for
2738/// each partition. Each of these files has its own local `page_idx` column, where the indices
2739/// start from 0.
2740///
2741/// This function's primary goal is to combine these separate files into one large
2742/// `page_lookup.lance` file. To do this, it remaps the local `page_idx` from each partition
2743/// file into a contiguous, global `page_idx` space. It processes partition files sequentially,
2744/// calculating an offset based on the number of pages in all previously processed partitions.
2745///
2746/// **The reverse operation occurs when the B-Tree index is loaded**: a global `page_idx` is translated
2747/// back into a `(partition_id, local_page_idx)` tuple. This translation is made possible by the
2748/// metadata stored under the `PAGE_NUM_PER_RANGE_PARTITION_META_KEY`, which this function
2749/// is responsible for writing.
2750///
2751/// # Examples
2752///
2753/// If we have two partition lookup files:
2754/// - `part_0_page_lookup.lance`: Contains 3 pages. Its `page_idx` column is `[0, 1, 2]`.
2755/// - `part_1_page_lookup.lance`: Contains 4 pages. Its `page_idx` column is `[0, 1, 2, 3]`.
2756///
2757/// The merge process works as follows:
2758/// 1. Process `part_0`: The offset is 0. The indices `[0, 1, 2]` are written as is.
2759/// 2. Process `part_1`: The offset is 3 and the local indices `[0, 1, 2, 3]` are remapped
2760///    by adding the offset, resulting in `[3, 4, 5, 6]`.
2761///
2762/// The final, merged `_page_lookup.lance` will have a single `page_idx` column containing
2763/// `[0, 1, 2, 3, 4, 5, 6]`.
2764async fn merge_range_partitioned_lookups(
2765    store: &dyn IndexStore,
2766    part_lookup_files: &[String],
2767    lookup_schema: Arc<Schema>,
2768    mut metadata: HashMap<String, String>,
2769    batch_size: u64,
2770    batch_readhead: Option<usize>,
2771    progress: Arc<dyn IndexBuildProgress>,
2772) -> Result<IndexFile> {
2773    let sorted_part_lookup_files = sort_files_by_partition_id(part_lookup_files)?;
2774    let mut lookup_file = store
2775        .new_index_file(BTREE_LOOKUP_NAME, lookup_schema)
2776        .await?;
2777
2778    // stores partition id and the number of pages in that partition
2779    let mut pages_per_file: Vec<(u64, u32)> = Vec::with_capacity(sorted_part_lookup_files.len());
2780    let mut num_pages_written = 0u32;
2781
2782    progress
2783        .stage_start(
2784            "merge_lookups",
2785            Some(sorted_part_lookup_files.len() as u64),
2786            "files",
2787        )
2788        .await?;
2789
2790    for (idx, (part_id, part_lookup_file)) in sorted_part_lookup_files.into_iter().enumerate() {
2791        let lookup_reader = store.open_index_file(&part_lookup_file).await?;
2792        let reader_stream = IndexReaderStream::new(lookup_reader.clone(), batch_size).await;
2793        let mut stream = reader_stream.buffered(batch_readhead.unwrap_or(1)).boxed();
2794        while let Some(batch) = stream.next().await {
2795            let original_batch = batch?;
2796            let modified_batch = add_offset_to_page_idx(&original_batch, num_pages_written)?;
2797            lookup_file.write_record_batch(modified_batch).await?;
2798        }
2799        pages_per_file.push((part_id, lookup_reader.num_rows() as u32));
2800        num_pages_written += lookup_reader.num_rows() as u32;
2801        progress
2802            .stage_progress("merge_lookups", idx as u64 + 1)
2803            .await?;
2804    }
2805
2806    metadata.insert(RANGE_PARTITIONED_META_KEY.to_string(), "true".to_string());
2807    metadata.insert(
2808        PAGE_NUM_PER_RANGE_PARTITION_META_KEY.to_string(),
2809        serde_json::to_string(&pages_per_file)?,
2810    );
2811
2812    let lookup_file = lookup_file.finish_with_metadata(metadata).await?;
2813    progress.stage_complete("merge_lookups").await?;
2814
2815    // In this mode, we only clean up lookup files, and page files are untouched.
2816    cleanup_partition_files(store, part_lookup_files, &[]).await;
2817    Ok(lookup_file)
2818}
2819
2820/// Merges partition files using a K-way sort-merge algorithm.
2821///
2822/// This function assumes its inputs have been pre-validated. It reads from all
2823/// partitioned page files simultaneously, merges them into a single sorted stream,
2824/// writes a new global page file, and generates a corresponding global lookup file.
2825#[allow(clippy::too_many_arguments)]
2826async fn merge_pages_and_lookups(
2827    store: &dyn IndexStore,
2828    part_page_files: &[String],
2829    part_lookup_files: &[String],
2830    page_files_map: &HashMap<u64, &String>,
2831    lookup_schema: Arc<Schema>,
2832    metadata: HashMap<String, String>,
2833    batch_size: u64,
2834    batch_readhead: Option<usize>,
2835    progress: Arc<dyn IndexBuildProgress>,
2836) -> Result<Vec<IndexFile>> {
2837    // Create a new global page file
2838    let partition_id = extract_partition_id(part_lookup_files[0].as_str())?;
2839    let page_file = page_files_map.get(&partition_id).unwrap();
2840    let page_reader = store.open_index_file(page_file).await?;
2841    let page_schema = page_reader.schema().clone();
2842
2843    let arrow_schema = Arc::new(Schema::from(&page_schema));
2844    let mut page_file = store
2845        .new_index_file(BTREE_PAGES_NAME, arrow_schema.clone())
2846        .await?;
2847    progress.stage_start("merge_pages", None, "pages").await?;
2848    let lookup_entries = merge_pages(
2849        part_lookup_files,
2850        page_files_map,
2851        store,
2852        batch_size,
2853        &mut page_file,
2854        arrow_schema.clone(),
2855        batch_readhead,
2856        progress.clone(),
2857    )
2858    .await?;
2859    let page_file = page_file.finish().await?;
2860    progress.stage_complete("merge_pages").await?;
2861
2862    let lookup_batch = RecordBatch::try_new(
2863        lookup_schema.clone(),
2864        vec![
2865            ScalarValue::iter_to_array(lookup_entries.iter().map(|(min, _, _, _)| min.clone()))?,
2866            ScalarValue::iter_to_array(lookup_entries.iter().map(|(_, max, _, _)| max.clone()))?,
2867            Arc::new(UInt32Array::from_iter_values(
2868                lookup_entries
2869                    .iter()
2870                    .map(|(_, _, null_count, _)| *null_count),
2871            )),
2872            Arc::new(UInt32Array::from_iter_values(
2873                lookup_entries.iter().map(|(_, _, _, page_idx)| *page_idx),
2874            )),
2875        ],
2876    )?;
2877    let mut lookup_file = store
2878        .new_index_file(BTREE_LOOKUP_NAME, lookup_schema)
2879        .await?;
2880    progress
2881        .stage_start("write_lookup_file", Some(1), "files")
2882        .await?;
2883    lookup_file.write_record_batch(lookup_batch).await?;
2884    let lookup_file = lookup_file.finish_with_metadata(metadata).await?;
2885    progress.stage_progress("write_lookup_file", 1).await?;
2886    progress.stage_complete("write_lookup_file").await?;
2887
2888    // After successfully writing the merged files, delete all partition files
2889    // Only perform deletion after files are successfully written, ensuring debug information is not lost in case of failure
2890    cleanup_partition_files(store, part_lookup_files, part_page_files).await;
2891
2892    Ok(vec![page_file, lookup_file])
2893}
2894
2895// Adjust local_page_idx_ in each look-up file to create a contiguous global_page_idx
2896fn add_offset_to_page_idx(batch: &RecordBatch, offset: u32) -> Result<RecordBatch> {
2897    let (page_idx_pos, _) = batch.schema().column_with_name("page_idx").ok_or_else(|| {
2898        Error::internal("Column 'page_idx' not found in RecordBatch schema".to_string())
2899    })?;
2900    let page_idx_array = batch
2901        .column(page_idx_pos)
2902        .as_any()
2903        .downcast_ref::<UInt32Array>()
2904        .ok_or_else(|| {
2905            Error::internal("Failed to downcast 'page_idx' column to UInt32Array".to_string())
2906        })?;
2907    let offset_array = UInt32Array::from(vec![offset; page_idx_array.len()]);
2908    let new_page_idx_array_ref = add(page_idx_array, &offset_array)?;
2909    let mut new_columns = batch.columns().to_vec();
2910    new_columns[page_idx_pos] = new_page_idx_array_ref;
2911    let new_batch = RecordBatch::try_new(batch.schema(), new_columns)?;
2912    Ok(new_batch)
2913}
2914
2915/// Merge pages using Datafusion's SortPreservingMergeExec
2916/// which implements a K-way merge algorithm with fixed-size output batches
2917#[allow(clippy::too_many_arguments)]
2918async fn merge_pages(
2919    part_lookup_files: &[String],
2920    page_files_map: &HashMap<u64, &String>,
2921    store: &dyn IndexStore,
2922    batch_size: u64,
2923    page_file: &mut Box<dyn IndexWriter>,
2924    arrow_schema: Arc<Schema>,
2925    batch_readhead: Option<usize>,
2926    progress: Arc<dyn IndexBuildProgress>,
2927) -> Result<Vec<(ScalarValue, ScalarValue, u32, u32)>> {
2928    let mut lookup_entries = Vec::new();
2929    let mut page_idx = 0u32;
2930
2931    debug!(
2932        "Starting SortPreservingMerge with {} partitions",
2933        part_lookup_files.len()
2934    );
2935
2936    let value_field = arrow_schema.field(0).clone().with_name(VALUE_COLUMN_NAME);
2937    let row_id_field = arrow_schema.field(1).clone().with_name(ROW_ID);
2938    let stream_schema = Arc::new(Schema::new(vec![value_field, row_id_field]));
2939
2940    // Create execution plans for each stream
2941    let mut inputs: Vec<Arc<dyn ExecutionPlan>> = Vec::new();
2942    for lookup_file in part_lookup_files {
2943        let partition_id = extract_partition_id(lookup_file)?;
2944        let page_file_name = (*page_files_map.get(&partition_id).ok_or_else(|| {
2945            Error::internal(format!(
2946                "Page file not found for partition ID: {}",
2947                partition_id
2948            ))
2949        })?)
2950        .clone();
2951
2952        let reader = store.open_index_file(&page_file_name).await?;
2953
2954        let reader_stream = IndexReaderStream::new(reader, batch_size).await;
2955
2956        let stream = reader_stream
2957            .map(|fut| fut.map_err(DataFusionError::from))
2958            .buffered(batch_readhead.unwrap_or(1))
2959            .boxed();
2960
2961        let sendable_stream =
2962            Box::pin(RecordBatchStreamAdapter::new(stream_schema.clone(), stream));
2963        inputs.push(Arc::new(OneShotExec::new(sendable_stream)));
2964    }
2965
2966    // Create Union execution plan to combine all partitions
2967    let union_inputs = UnionExec::try_new(inputs)?;
2968
2969    // Create SortPreservingMerge execution plan
2970    let value_column_index = stream_schema.index_of(VALUE_COLUMN_NAME)?;
2971    let sort_expr = PhysicalSortExpr {
2972        expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_index)),
2973        options: SortOptions {
2974            descending: false,
2975            nulls_first: true,
2976        },
2977    };
2978
2979    let merge_exec = Arc::new(SortPreservingMergeExec::new(
2980        [sort_expr].into(),
2981        union_inputs,
2982    ));
2983
2984    let unchunked = execute_plan(
2985        merge_exec,
2986        LanceExecutionOptions {
2987            use_spilling: false,
2988            ..Default::default()
2989        },
2990    )?;
2991
2992    // Use chunk_concat_stream to ensure fixed batch sizes
2993    let mut chunked_stream = chunk_concat_stream(unchunked, batch_size as usize);
2994
2995    // Process chunked stream
2996    while let Some(batch) = chunked_stream.try_next().await? {
2997        let writer_batch = RecordBatch::try_new(
2998            arrow_schema.clone(),
2999            vec![batch.column(0).clone(), batch.column(1).clone()],
3000        )?;
3001
3002        page_file.write_record_batch(writer_batch).await?;
3003
3004        let min_val = ScalarValue::try_from_array(batch.column(0), 0)?;
3005        let max_val = ScalarValue::try_from_array(batch.column(0), batch.num_rows() - 1)?;
3006        let null_count = batch.column(0).null_count() as u32;
3007
3008        lookup_entries.push((min_val, max_val, null_count, page_idx));
3009        page_idx += 1;
3010        progress
3011            .stage_progress("merge_pages", page_idx as u64)
3012            .await?;
3013    }
3014
3015    Ok(lookup_entries)
3016}
3017
3018// Sorts file paths by the partition ID extracted from file name.
3019fn sort_files_by_partition_id(part_files: &[String]) -> Result<Vec<(u64, String)>> {
3020    let mut files_with_ids: Vec<(u64, &String)> = part_files
3021        .iter()
3022        .map(|file| extract_partition_id(file).map(|id| (id, file)))
3023        .collect::<Result<Vec<_>>>()?;
3024
3025    files_with_ids.sort_unstable_by_key(|k| k.0);
3026
3027    let sorted_files = files_with_ids
3028        .into_iter()
3029        .map(|(id, file)| (id, file.clone()))
3030        .collect();
3031
3032    Ok(sorted_files)
3033}
3034
3035/// Extract partition ID from partition file name
3036/// Expected format: "part_{partition_id}_{suffix}.lance"
3037fn extract_partition_id(filename: &str) -> Result<u64> {
3038    if !filename.starts_with("part_") {
3039        return Err(Error::internal(format!(
3040            "Invalid partition file name format: {}",
3041            filename
3042        )));
3043    }
3044
3045    let parts: Vec<&str> = filename.split('_').collect();
3046    if parts.len() < 3 {
3047        return Err(Error::internal(format!(
3048            "Invalid partition file name format: {}",
3049            filename
3050        )));
3051    }
3052
3053    parts[1].parse::<u64>().map_err(|_| {
3054        Error::internal(format!(
3055            "Failed to parse partition ID from filename: {}",
3056            filename
3057        ))
3058    })
3059}
3060
3061/// Clean up partition files after successful merge
3062///
3063/// This function safely deletes partition lookup and page files after a successful merge operation.
3064/// File deletion failures are logged but do not affect the overall success of the merge operation.
3065async fn cleanup_partition_files(
3066    store: &dyn IndexStore,
3067    part_lookup_files: &[String],
3068    part_page_files: &[String],
3069) {
3070    // Clean up partition lookup files
3071    for file_name in part_lookup_files {
3072        cleanup_single_file(
3073            store,
3074            file_name,
3075            "part_",
3076            "_page_lookup.lance",
3077            "partition lookup",
3078        )
3079        .await;
3080    }
3081
3082    // Clean up partition page files
3083    for file_name in part_page_files {
3084        cleanup_single_file(
3085            store,
3086            file_name,
3087            "part_",
3088            "_page_data.lance",
3089            "partition page",
3090        )
3091        .await;
3092    }
3093}
3094
3095/// Helper function to clean up a single partition file
3096///
3097/// Performs safety checks on the filename pattern before attempting deletion.
3098async fn cleanup_single_file(
3099    store: &dyn IndexStore,
3100    file_name: &str,
3101    expected_prefix: &str,
3102    expected_suffix: &str,
3103    file_type: &str,
3104) {
3105    if file_name.starts_with(expected_prefix) && file_name.ends_with(expected_suffix) {
3106        match store.delete_index_file(file_name).await {
3107            Ok(()) => {
3108                debug!("Successfully deleted {} file: {}", file_type, file_name);
3109            }
3110            Err(e) => {
3111                warn!(
3112                    "Failed to delete {} file '{}': {}. \
3113                    This does not affect the merge operation, but may leave \
3114                    partition files that should be cleaned up manually.",
3115                    file_type, file_name, e
3116                );
3117            }
3118        }
3119    } else {
3120        // If the filename doesn't match the expected format, log a warning but don't attempt deletion
3121        warn!(
3122            "Skipping deletion of file '{}' as it does not match the expected \
3123            {} file pattern ({}*{})",
3124            file_name, file_type, expected_prefix, expected_suffix
3125        );
3126    }
3127}
3128
3129pub(crate) fn part_page_data_file_path(partition_id: u64) -> String {
3130    format!("part_{}_{}", partition_id, BTREE_PAGES_NAME)
3131}
3132
3133pub(crate) fn part_lookup_file_path(partition_id: u64) -> String {
3134    format!("part_{}_{}", partition_id, BTREE_LOOKUP_NAME)
3135}
3136
3137/// A stream that reads the original training data back out of the index
3138///
3139/// This is used for updating the index
3140struct IndexReaderStream {
3141    reader: Arc<dyn IndexReader>,
3142    batch_size: u64,
3143    num_batches: u32,
3144    batch_idx: u32,
3145}
3146
3147impl IndexReaderStream {
3148    async fn new(reader: Arc<dyn IndexReader>, batch_size: u64) -> Self {
3149        let num_batches = reader.num_batches(batch_size).await;
3150        Self {
3151            reader,
3152            batch_size,
3153            num_batches,
3154            batch_idx: 0,
3155        }
3156    }
3157}
3158
3159impl Stream for IndexReaderStream {
3160    type Item = BoxFuture<'static, Result<RecordBatch>>;
3161
3162    fn poll_next(
3163        self: std::pin::Pin<&mut Self>,
3164        _cx: &mut std::task::Context<'_>,
3165    ) -> std::task::Poll<Option<Self::Item>> {
3166        let this = self.get_mut();
3167        if this.batch_idx >= this.num_batches {
3168            return std::task::Poll::Ready(None);
3169        }
3170        let batch_num = this.batch_idx;
3171        this.batch_idx += 1;
3172        let reader_copy = this.reader.clone();
3173        let batch_size = this.batch_size;
3174        let read_task = async move {
3175            reader_copy
3176                .read_record_batch(batch_num as u64, batch_size)
3177                .await
3178        }
3179        .boxed();
3180        std::task::Poll::Ready(Some(read_task))
3181    }
3182}
3183
3184/// Parameters for a btree index
3185#[derive(Debug, Serialize, Deserialize)]
3186pub struct BTreeParameters {
3187    /// The number of rows to include in each zone
3188    pub zone_size: Option<u64>,
3189
3190    /// DEPRECATED: range-based distributed BTree building has been retired.
3191    /// Setting this to `Some(..)` now emits a warning and is ignored at build time
3192    /// (see `BTreeIndexPlugin::train_index`). Build one segment per worker and
3193    /// commit them with `commit_existing_index_segments(...)`, optionally
3194    /// consolidating with `merge_existing_index_segments(...)`. The field is
3195    /// retained (rather than removed) so the plugin can detect stale `range_id`
3196    /// inputs and warn loudly instead of serde silently dropping an unknown field.
3197    ///
3198    /// Historically, this was the ordinal ID of a globally sorted range
3199    /// partition. Lance used it to write `part_*` BTree files that were later
3200    /// merged by `merge_index_metadata`. That flow has been retired. A
3201    /// pre-sorted training stream is still accepted, but this field no longer
3202    /// affects file names, commit behavior, or query semantics.
3203    pub range_id: Option<u32>,
3204}
3205
3206struct BTreeTrainingRequest {
3207    parameters: BTreeParameters,
3208    criteria: TrainingCriteria,
3209}
3210
3211impl BTreeTrainingRequest {
3212    pub fn new(parameters: BTreeParameters) -> Self {
3213        Self {
3214            parameters,
3215            // BTree indexes need data sorted by the value column
3216            criteria: TrainingCriteria::new(TrainingOrdering::Values).with_row_id(),
3217        }
3218    }
3219}
3220
3221impl TrainingRequest for BTreeTrainingRequest {
3222    fn as_any(&self) -> &dyn std::any::Any {
3223        self
3224    }
3225
3226    fn criteria(&self) -> &TrainingCriteria {
3227        &self.criteria
3228    }
3229}
3230
3231#[derive(Debug, Default)]
3232pub struct BTreeIndexPlugin;
3233
3234#[async_trait]
3235impl BasicTrainer for BTreeIndexPlugin {
3236    fn new_training_request(
3237        &self,
3238        params: &str,
3239        field: &Field,
3240    ) -> Result<Box<dyn TrainingRequest>> {
3241        if field.data_type().is_nested() {
3242            return Err(Error::invalid_input_source(
3243                "A btree index can only be created on a non-nested field.".into(),
3244            ));
3245        }
3246
3247        let params = serde_json::from_str::<BTreeParameters>(params)?;
3248        Ok(Box::new(BTreeTrainingRequest::new(params)))
3249    }
3250
3251    async fn train_index(
3252        &self,
3253        data: SendableRecordBatchStream,
3254        index_store: &dyn IndexStore,
3255        request: Box<dyn TrainingRequest>,
3256        _fragment_ids: Option<Vec<u32>>,
3257        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
3258    ) -> Result<CreatedIndex> {
3259        let request = request
3260            .as_any()
3261            .downcast_ref::<BTreeTrainingRequest>()
3262            .unwrap();
3263        if request.parameters.range_id.is_some() {
3264            // `range_id` is deprecated and now ignored. A pre-sorted data stream is
3265            // still supported (pass it as the training data), but `range_id` no longer
3266            // needs to be set: each build now produces one canonical segment, and
3267            // distribution is handled by the segmented-index APIs. The field will be
3268            // removed in a future release.
3269            warn!(
3270                "BTree `range_id` is deprecated and now ignored; a pre-sorted data \
3271                 stream is still supported, but `range_id` no longer needs to be passed. \
3272                 Use the segmented-index APIs instead (build per-fragment segments, then \
3273                 commit_existing_index_segments(...) / merge_existing_index_segments(...)). \
3274                 The `range_id` field will be removed in a future release."
3275            );
3276        }
3277        let files = train_btree_index(
3278            data,
3279            index_store,
3280            request
3281                .parameters
3282                .zone_size
3283                .unwrap_or(DEFAULT_BTREE_BATCH_SIZE),
3284            None,
3285            None,
3286        )
3287        .await?;
3288        Ok(CreatedIndex {
3289            index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
3290                .unwrap(),
3291            index_version: BTREE_INDEX_VERSION,
3292            files,
3293        })
3294    }
3295}
3296
3297#[async_trait]
3298impl ScalarIndexPlugin for BTreeIndexPlugin {
3299    fn basic_trainer(&self) -> Option<&dyn BasicTrainer> {
3300        Some(self)
3301    }
3302
3303    fn name(&self) -> &str {
3304        "BTree"
3305    }
3306
3307    fn provides_exact_answer(&self) -> bool {
3308        true
3309    }
3310
3311    fn version(&self) -> u32 {
3312        BTREE_INDEX_VERSION
3313    }
3314
3315    fn new_query_parser(
3316        &self,
3317        index_name: String,
3318        _index_details: &prost_types::Any,
3319    ) -> Option<Box<dyn ScalarQueryParser>> {
3320        Some(Box::new(SargableQueryParser::new(
3321            index_name,
3322            self.name().to_string(),
3323            false,
3324        )))
3325    }
3326
3327    async fn load_index(
3328        &self,
3329        index_store: Arc<dyn IndexStore>,
3330        _index_details: &prost_types::Any,
3331        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
3332        cache: &LanceCache,
3333    ) -> Result<Arc<dyn ScalarIndex>> {
3334        Ok(BTreeIndex::load(index_store, frag_reuse_index, cache).await? as Arc<dyn ScalarIndex>)
3335    }
3336
3337    async fn get_from_cache(
3338        &self,
3339        index_store: Arc<dyn IndexStore>,
3340        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
3341        cache: &LanceCache,
3342    ) -> Result<Option<Arc<dyn ScalarIndex>>> {
3343        let Some(state) = cache.get_with_key(&BTreeIndexStateKey).await else {
3344            return Ok(None);
3345        };
3346        Ok(Some(state.reconstruct(
3347            index_store,
3348            cache,
3349            frag_reuse_index,
3350        )?))
3351    }
3352
3353    async fn put_in_cache(&self, cache: &LanceCache, index: Arc<dyn ScalarIndex>) -> Result<()> {
3354        let state = BTreeIndexState::from_index(index.as_ref())?;
3355        cache
3356            .insert_with_key(&BTreeIndexStateKey, Arc::new(state))
3357            .await;
3358        Ok(())
3359    }
3360
3361    async fn get_or_insert_in_cache(
3362        &self,
3363        index_store: Arc<dyn IndexStore>,
3364        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
3365        cache: &LanceCache,
3366        load: ScalarIndexLoad<'_>,
3367    ) -> Result<Arc<dyn ScalarIndex>> {
3368        single_flight_open(
3369            cache,
3370            BTreeIndexStateKey,
3371            load,
3372            BTreeIndexState::from_index,
3373            move |state| state.reconstruct(index_store, cache, frag_reuse_index),
3374        )
3375        .await
3376    }
3377}
3378
3379#[cfg(test)]
3380mod tests {
3381    use lance_core::utils::row_addr_remap::RowAddrRemap;
3382    use std::sync::atomic::Ordering;
3383    use std::{collections::HashMap, sync::Arc};
3384
3385    use arrow::datatypes::{Float32Type, Float64Type, Int32Type, UInt64Type};
3386    use arrow_array::{FixedSizeListArray, record_batch};
3387    use datafusion::{
3388        execution::{SendableRecordBatchStream, TaskContext},
3389        physical_plan::{ExecutionPlan, sorts::sort::SortExec, stream::RecordBatchStreamAdapter},
3390    };
3391    use datafusion_common::{DataFusionError, ScalarValue};
3392    use datafusion_physical_expr::{PhysicalSortExpr, expressions::col};
3393    use futures::TryStreamExt;
3394    use futures::stream;
3395    use lance_core::cache::LanceCache;
3396    use lance_core::deepsize::DeepSizeOf;
3397    use lance_core::utils::tempfile::TempObjDir;
3398    use lance_datafusion::{chunker::break_stream, datagen::DatafusionDatagenExt};
3399    use lance_datagen::{ArrayGeneratorExt, BatchCount, RowCount, array, gen_batch};
3400    use lance_io::object_store::ObjectStore;
3401    use lance_select::{RowAddrTreeMap, RowSetOps};
3402    use object_store::path::Path;
3403
3404    use crate::metrics::LocalMetricsCollector;
3405    use crate::progress::{IndexBuildProgress, noop_progress};
3406    use crate::{
3407        metrics::NoOpMetricsCollector,
3408        scalar::{
3409            IndexStore, OldIndexDataFilter, SargableQuery, ScalarIndex, SearchResult,
3410            btree::{BTREE_PAGES_NAME, BTreeIndex},
3411            lance_format::LanceIndexStore,
3412        },
3413    };
3414
3415    use super::{
3416        BTreeIndexPlugin, BTreeIndexState, BTreeLookup, BTreePageKey, DEFAULT_BTREE_BATCH_SIZE,
3417        Matches, OrderableScalarValue, part_lookup_file_path, part_page_data_file_path,
3418        train_btree_index,
3419    };
3420    use crate::scalar::registry::ScalarIndexPlugin;
3421    use arrow_array::RecordBatch;
3422    use lance_core::cache::{CacheCodecImpl, CacheEntryReader, CacheEntryWriter, CacheKey};
3423
3424    /// Serialize a `BTreeIndexState` body (no envelope) for tests.
3425    fn serialize_state(state: &BTreeIndexState) -> Vec<u8> {
3426        let mut buf = Vec::new();
3427        state
3428            .serialize(&mut CacheEntryWriter::new(&mut buf))
3429            .unwrap();
3430        buf
3431    }
3432
3433    /// Deserialize a `BTreeIndexState` body (no envelope) for tests.
3434    fn deserialize_state(buf: Vec<u8>) -> lance_core::Result<BTreeIndexState> {
3435        let data = bytes::Bytes::from(buf);
3436        let mut reader = CacheEntryReader::new(&data, 0, BTreeIndexState::CURRENT_VERSION);
3437        BTreeIndexState::deserialize(&mut reader)
3438    }
3439    use rangemap::RangeInclusiveMap;
3440
3441    lance_testing::define_stage_event_progress!(
3442        RecordingProgress,
3443        IndexBuildProgress,
3444        lance_core::Result<()>
3445    );
3446    #[test]
3447    fn test_scalar_value_size() {
3448        let size_of_i32 = OrderableScalarValue(ScalarValue::Int32(Some(0))).deep_size_of();
3449        let size_of_many_i32 = OrderableScalarValue(ScalarValue::FixedSizeList(Arc::new(
3450            FixedSizeListArray::from_iter_primitive::<Int32Type, _, _>(
3451                vec![Some(vec![Some(0); 128])],
3452                128,
3453            ),
3454        )))
3455        .deep_size_of();
3456
3457        // deep_size_of should account for the rust type overhead
3458        assert!(size_of_i32 > 4);
3459        assert!(size_of_many_i32 > 128 * 4);
3460    }
3461
3462    #[test]
3463    fn test_orderable_dictionary_cmp() {
3464        use arrow_schema::DataType;
3465        use std::cmp::Ordering;
3466
3467        let dict = |s: &str, key: DataType| {
3468            OrderableScalarValue(ScalarValue::Dictionary(
3469                Box::new(key),
3470                Box::new(ScalarValue::Utf8(Some(s.to_string()))),
3471            ))
3472        };
3473
3474        // Dictionary scalars are ordered by their underlying value, regardless
3475        // of the key type. This is exercised when loading a scalar index built
3476        // on a dictionary-encoded column into a BTreeMap.
3477        assert_eq!(
3478            dict("a", DataType::Int16).cmp(&dict("b", DataType::Int16)),
3479            Ordering::Less
3480        );
3481        assert_eq!(
3482            dict("b", DataType::Int32).cmp(&dict("b", DataType::Int16)),
3483            Ordering::Equal
3484        );
3485
3486        // A non-null dictionary value sorts after null.
3487        assert_eq!(
3488            dict("a", DataType::Int16).cmp(&OrderableScalarValue(ScalarValue::Null)),
3489            Ordering::Greater
3490        );
3491    }
3492
3493    #[tokio::test]
3494    async fn test_null_ids() {
3495        let tmpdir = TempObjDir::default();
3496        let test_store = Arc::new(LanceIndexStore::new(
3497            Arc::new(ObjectStore::local()),
3498            tmpdir.clone(),
3499            Arc::new(LanceCache::no_cache()),
3500        ));
3501
3502        // Generate 50,000 rows of random data with 80% nulls
3503        let stream = gen_batch()
3504            .col(
3505                "value",
3506                array::rand::<Float32Type>().with_nulls(&[true, false, false, false, false]),
3507            )
3508            .col("_rowid", array::step::<UInt64Type>())
3509            .into_df_stream(RowCount::from(5000), BatchCount::from(10));
3510
3511        train_btree_index(stream, test_store.as_ref(), 5000, None, None)
3512            .await
3513            .unwrap();
3514
3515        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
3516            .await
3517            .unwrap();
3518
3519        assert_eq!(index.page_lookup.null_pages.len(), 10);
3520
3521        let remap_dir = TempObjDir::default();
3522        let remap_store = Arc::new(LanceIndexStore::new(
3523            Arc::new(ObjectStore::local()),
3524            remap_dir.clone(),
3525            Arc::new(LanceCache::no_cache()),
3526        ));
3527
3528        // Remap with a no-op mapping.  The remapped index should be identical to the original
3529        index
3530            .remap(&RowAddrRemap::empty(), remap_store.as_ref())
3531            .await
3532            .unwrap();
3533
3534        let remap_index = BTreeIndex::load(remap_store.clone(), None, &LanceCache::no_cache())
3535            .await
3536            .unwrap();
3537
3538        assert_eq!(remap_index.page_lookup, index.page_lookup);
3539
3540        let original_pages = test_store.open_index_file(BTREE_PAGES_NAME).await.unwrap();
3541        let remapped_pages = remap_store.open_index_file(BTREE_PAGES_NAME).await.unwrap();
3542
3543        assert_eq!(original_pages.num_rows(), remapped_pages.num_rows());
3544
3545        let original_data = original_pages
3546            .read_record_batch(0, original_pages.num_rows() as u64)
3547            .await
3548            .unwrap();
3549        let remapped_data = remapped_pages
3550            .read_record_batch(0, remapped_pages.num_rows() as u64)
3551            .await
3552            .unwrap();
3553
3554        assert_eq!(original_data, remapped_data);
3555    }
3556
3557    #[tokio::test]
3558    async fn test_nan_ordering() {
3559        let tmpdir = TempObjDir::default();
3560        let test_store = Arc::new(LanceIndexStore::new(
3561            Arc::new(ObjectStore::local()),
3562            tmpdir.clone(),
3563            Arc::new(LanceCache::no_cache()),
3564        ));
3565
3566        let values = vec![
3567            0.0,
3568            1.0,
3569            2.0,
3570            3.0,
3571            f64::NAN,
3572            f64::NEG_INFINITY,
3573            f64::INFINITY,
3574        ];
3575
3576        // This is a bit overkill but we've had bugs in the past where DF's sort
3577        // didn't agree with Arrow's sort so we do an end-to-end test here
3578        // and use DF to sort the data like we would in a real dataset.
3579        let data = gen_batch()
3580            .col("value", array::cycle::<Float64Type>(values.clone()))
3581            .col("_rowid", array::step::<UInt64Type>())
3582            .into_df_exec(RowCount::from(10), BatchCount::from(100));
3583        let schema = data.schema();
3584        let sort_expr = PhysicalSortExpr::new_default(col("value", schema.as_ref()).unwrap());
3585        let plan = Arc::new(SortExec::new([sort_expr].into(), data));
3586        let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
3587        let stream = break_stream(stream, 64);
3588        let stream = stream.map_err(DataFusionError::from);
3589        let stream =
3590            Box::pin(RecordBatchStreamAdapter::new(schema, stream)) as SendableRecordBatchStream;
3591
3592        train_btree_index(stream, test_store.as_ref(), 64, None, None)
3593            .await
3594            .unwrap();
3595
3596        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3597            .await
3598            .unwrap();
3599
3600        for (idx, value) in values.into_iter().enumerate() {
3601            let query = SargableQuery::Equals(ScalarValue::Float64(Some(value)));
3602            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3603            assert_eq!(
3604                result,
3605                SearchResult::exact(RowAddrTreeMap::from_iter(((idx as u64)..1000).step_by(7)))
3606            );
3607        }
3608    }
3609
3610    #[tokio::test]
3611    async fn test_contains_keys_matches_search() {
3612        let tmpdir = TempObjDir::default();
3613        let test_store = Arc::new(LanceIndexStore::new(
3614            Arc::new(ObjectStore::local()),
3615            tmpdir.clone(),
3616            Arc::new(LanceCache::no_cache()),
3617        ));
3618
3619        // 1000 distinct Int32 values [0, 1000), spread across many small pages
3620        // (batch_size 64) so the keys below exercise multi-page grouping.
3621        let data = gen_batch()
3622            .col("value", array::step::<Int32Type>())
3623            .col("_rowid", array::step::<UInt64Type>())
3624            .into_df_exec(RowCount::from(100), BatchCount::from(10));
3625        let schema = data.schema();
3626        let sort_expr = PhysicalSortExpr::new_default(col("value", schema.as_ref()).unwrap());
3627        let plan = Arc::new(SortExec::new([sort_expr].into(), data));
3628        let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
3629        let stream = break_stream(stream, 64);
3630        let stream = stream.map_err(DataFusionError::from);
3631        let stream =
3632            Box::pin(RecordBatchStreamAdapter::new(schema, stream)) as SendableRecordBatchStream;
3633
3634        train_btree_index(stream, test_store.as_ref(), 64, None, None)
3635            .await
3636            .unwrap();
3637        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3638            .await
3639            .unwrap();
3640
3641        // Present (range ends, mid, and adjacent values that straddle page
3642        // boundaries), interleaved with absent (below/above range, and a gap).
3643        let keys: Vec<i32> = vec![0, 999, 500, 1, 998, -1, 1000, 1500, 250, 251, 7, 64, 63, 65];
3644        let scalar_keys: Vec<ScalarValue> =
3645            keys.iter().map(|k| ScalarValue::Int32(Some(*k))).collect();
3646
3647        let batched = index
3648            .contains_keys(&scalar_keys, &NoOpMetricsCollector)
3649            .await
3650            .unwrap();
3651
3652        // Oracle: the per-key Equals search the batched path replaces.
3653        let mut oracle = Vec::with_capacity(keys.len());
3654        for k in &scalar_keys {
3655            let result = index
3656                .search(&SargableQuery::Equals(k.clone()), &NoOpMetricsCollector)
3657                .await
3658                .unwrap();
3659            oracle.push(!result.row_addrs().is_empty());
3660        }
3661        assert_eq!(
3662            batched, oracle,
3663            "contains_keys must agree with per-key Equals search; keys={keys:?}"
3664        );
3665
3666        // And both must match ground truth: [0, 1000) present, others absent.
3667        let expected: Vec<bool> = keys.iter().map(|k| (0..1000).contains(k)).collect();
3668        assert_eq!(batched, expected);
3669
3670        // Empty input → empty mask.
3671        assert!(
3672            index
3673                .contains_keys(&[], &NoOpMetricsCollector)
3674                .await
3675                .unwrap()
3676                .is_empty()
3677        );
3678
3679        // A null key maps to false (and must not panic).
3680        let with_null = vec![ScalarValue::Int32(Some(5)), ScalarValue::Int32(None)];
3681        assert_eq!(
3682            index
3683                .contains_keys(&with_null, &NoOpMetricsCollector)
3684                .await
3685                .unwrap(),
3686            vec![true, false]
3687        );
3688    }
3689
3690    #[tokio::test]
3691    async fn test_page_cache() {
3692        let tmpdir = TempObjDir::default();
3693        let test_store = Arc::new(LanceIndexStore::new(
3694            Arc::new(ObjectStore::local()),
3695            tmpdir.clone(),
3696            Arc::new(LanceCache::no_cache()),
3697        ));
3698
3699        let data = gen_batch()
3700            .col("value", array::step::<Float32Type>())
3701            .col("_rowid", array::step::<UInt64Type>())
3702            .into_df_exec(RowCount::from(1000), BatchCount::from(10));
3703        let schema = data.schema();
3704        let sort_expr = PhysicalSortExpr::new_default(col("value", schema.as_ref()).unwrap());
3705        let plan = Arc::new(SortExec::new([sort_expr].into(), data));
3706        let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
3707        let stream = break_stream(stream, 64);
3708        let stream = stream.map_err(DataFusionError::from);
3709        let stream =
3710            Box::pin(RecordBatchStreamAdapter::new(schema, stream)) as SendableRecordBatchStream;
3711
3712        train_btree_index(stream, test_store.as_ref(), 64, None, None)
3713            .await
3714            .unwrap();
3715
3716        let cache = Arc::new(LanceCache::with_capacity(100 * 1024 * 1024));
3717        let index = BTreeIndex::load(test_store, None, cache.as_ref())
3718            .await
3719            .unwrap();
3720
3721        let query = SargableQuery::Equals(ScalarValue::Float32(Some(0.0)));
3722        let metrics = LocalMetricsCollector::default();
3723        let query1 = index.search(&query, &metrics);
3724        let query2 = index.search(&query, &metrics);
3725        tokio::join!(query1, query2).0.unwrap();
3726        assert_eq!(metrics.parts_loaded.load(Ordering::Relaxed), 1);
3727    }
3728
3729    #[tokio::test]
3730    async fn test_page_cache_hit_miss_counts() {
3731        let tmpdir = TempObjDir::default();
3732        let test_store = Arc::new(LanceIndexStore::new(
3733            Arc::new(ObjectStore::local()),
3734            tmpdir.clone(),
3735            Arc::new(LanceCache::no_cache()),
3736        ));
3737
3738        let data = gen_batch()
3739            .col("value", array::step::<Float32Type>())
3740            .col("_rowid", array::step::<UInt64Type>())
3741            .into_df_exec(RowCount::from(1000), BatchCount::from(10));
3742        let schema = data.schema();
3743        let sort_expr = PhysicalSortExpr::new_default(col("value", schema.as_ref()).unwrap());
3744        let plan = Arc::new(SortExec::new([sort_expr].into(), data));
3745        let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
3746        let stream = break_stream(stream, 64);
3747        let stream = stream.map_err(DataFusionError::from);
3748        let stream =
3749            Box::pin(RecordBatchStreamAdapter::new(schema, stream)) as SendableRecordBatchStream;
3750
3751        train_btree_index(stream, test_store.as_ref(), 64, None, None)
3752            .await
3753            .unwrap();
3754
3755        let cache = Arc::new(LanceCache::with_capacity(100 * 1024 * 1024));
3756        let index = BTreeIndex::load(test_store, None, cache.as_ref())
3757            .await
3758            .unwrap();
3759
3760        // First search: cold cache — the page fetch must miss.
3761        let query = SargableQuery::Equals(ScalarValue::Float32(Some(0.0)));
3762        let cold = LocalMetricsCollector::default();
3763        index.search(&query, &cold).await.unwrap();
3764        assert_eq!(cold.index_cache_hits(), 0);
3765        assert_eq!(cold.index_cache_misses(), 1);
3766        assert_eq!(cold.parts_loaded.load(Ordering::Relaxed), 1);
3767
3768        // Second search: same key, page must now be served from cache.
3769        let warm = LocalMetricsCollector::default();
3770        index.search(&query, &warm).await.unwrap();
3771        assert_eq!(warm.index_cache_hits(), 1);
3772        assert_eq!(warm.index_cache_misses(), 0);
3773        assert_eq!(warm.parts_loaded.load(Ordering::Relaxed), 0);
3774    }
3775
3776    #[tokio::test]
3777    async fn test_like_prefix_search() {
3778        use arrow::datatypes::DataType;
3779        use arrow_array::StringArray;
3780
3781        let tmpdir = TempObjDir::default();
3782        let test_store = Arc::new(LanceIndexStore::new(
3783            Arc::new(ObjectStore::local()),
3784            tmpdir.clone(),
3785            Arc::new(LanceCache::no_cache()),
3786        ));
3787
3788        // Create string data with various prefixes
3789        let values = vec![
3790            "apple",
3791            "app",
3792            "application",
3793            "banana",
3794            "band",
3795            "test_ns$table1",
3796            "test_ns$table2",
3797            "test_ns2$table1",
3798            "test",
3799            "testing",
3800        ];
3801        let row_ids: Vec<u64> = (0..values.len() as u64).collect();
3802
3803        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
3804            arrow::datatypes::Field::new("value", DataType::Utf8, false),
3805            arrow::datatypes::Field::new("_rowid", DataType::UInt64, false),
3806        ]));
3807
3808        let batch = arrow::record_batch::RecordBatch::try_new(
3809            schema.clone(),
3810            vec![
3811                Arc::new(StringArray::from(values.clone())),
3812                Arc::new(arrow_array::UInt64Array::from(row_ids)),
3813            ],
3814        )
3815        .unwrap();
3816
3817        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
3818            schema,
3819            stream::once(async { Ok(batch) }),
3820        ));
3821
3822        train_btree_index(stream, test_store.as_ref(), 100, None, None)
3823            .await
3824            .unwrap();
3825
3826        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3827            .await
3828            .unwrap();
3829
3830        // Test LikePrefix for "app" - should match "apple", "app", "application" (row ids 0, 1, 2)
3831        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("app".to_string())));
3832        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3833
3834        match &result {
3835            SearchResult::Exact(row_ids) => {
3836                let ids: Vec<u64> = row_ids
3837                    .true_rows()
3838                    .row_addrs()
3839                    .unwrap()
3840                    .map(u64::from)
3841                    .collect();
3842                assert!(ids.contains(&0), "Should contain row 0 (apple)");
3843                assert!(ids.contains(&1), "Should contain row 1 (app)");
3844                assert!(ids.contains(&2), "Should contain row 2 (application)");
3845                assert!(!ids.contains(&3), "Should not contain row 3 (banana)");
3846            }
3847            _ => panic!("Expected Exact result"),
3848        }
3849
3850        // Test LikePrefix for "test_ns$" - should match "test_ns$table1", "test_ns$table2" (row ids 5, 6)
3851        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test_ns$".to_string())));
3852        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3853
3854        match &result {
3855            SearchResult::Exact(row_ids) => {
3856                let ids: Vec<u64> = row_ids
3857                    .true_rows()
3858                    .row_addrs()
3859                    .unwrap()
3860                    .map(u64::from)
3861                    .collect();
3862                assert!(ids.contains(&5), "Should contain row 5 (test_ns$table1)");
3863                assert!(ids.contains(&6), "Should contain row 6 (test_ns$table2)");
3864                assert!(
3865                    !ids.contains(&7),
3866                    "Should not contain row 7 (test_ns2$table1)"
3867                );
3868            }
3869            _ => panic!("Expected Exact result"),
3870        }
3871
3872        // Test LikePrefix for "test" - should match "test", "testing", "test_ns$table1", "test_ns$table2", "test_ns2$table1"
3873        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test".to_string())));
3874        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3875
3876        match &result {
3877            SearchResult::Exact(row_ids) => {
3878                let ids: Vec<u64> = row_ids
3879                    .true_rows()
3880                    .row_addrs()
3881                    .unwrap()
3882                    .map(u64::from)
3883                    .collect();
3884                assert!(
3885                    ids.contains(&5),
3886                    "Should contain row 5 (test_ns$table1): {:?}",
3887                    ids
3888                );
3889                assert!(
3890                    ids.contains(&6),
3891                    "Should contain row 6 (test_ns$table2): {:?}",
3892                    ids
3893                );
3894                assert!(
3895                    ids.contains(&7),
3896                    "Should contain row 7 (test_ns2$table1): {:?}",
3897                    ids
3898                );
3899                assert!(ids.contains(&8), "Should contain row 8 (test): {:?}", ids);
3900                assert!(
3901                    ids.contains(&9),
3902                    "Should contain row 9 (testing): {:?}",
3903                    ids
3904                );
3905            }
3906            _ => panic!("Expected Exact result"),
3907        }
3908    }
3909
3910    #[tokio::test]
3911    async fn test_like_prefix_search_large_utf8() {
3912        use arrow::datatypes::DataType;
3913        use arrow_array::LargeStringArray;
3914
3915        let tmpdir = TempObjDir::default();
3916        let test_store = Arc::new(LanceIndexStore::new(
3917            Arc::new(ObjectStore::local()),
3918            tmpdir.clone(),
3919            Arc::new(LanceCache::no_cache()),
3920        ));
3921
3922        let values = vec!["apple", "app", "application", "banana"];
3923        let row_ids: Vec<u64> = (0..values.len() as u64).collect();
3924
3925        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
3926            arrow::datatypes::Field::new("value", DataType::LargeUtf8, false),
3927            arrow::datatypes::Field::new("_rowid", DataType::UInt64, false),
3928        ]));
3929
3930        let batch = arrow::record_batch::RecordBatch::try_new(
3931            schema.clone(),
3932            vec![
3933                Arc::new(LargeStringArray::from(values)),
3934                Arc::new(arrow_array::UInt64Array::from(row_ids)),
3935            ],
3936        )
3937        .unwrap();
3938
3939        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
3940            schema,
3941            stream::once(async { Ok(batch) }),
3942        ));
3943
3944        train_btree_index(stream, test_store.as_ref(), 100, None, None)
3945            .await
3946            .unwrap();
3947
3948        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3949            .await
3950            .unwrap();
3951
3952        // Test LikePrefix with LargeUtf8
3953        let query = SargableQuery::LikePrefix(ScalarValue::LargeUtf8(Some("app".to_string())));
3954        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3955
3956        match &result {
3957            SearchResult::Exact(row_ids) => {
3958                let ids: Vec<u64> = row_ids
3959                    .true_rows()
3960                    .row_addrs()
3961                    .unwrap()
3962                    .map(u64::from)
3963                    .collect();
3964                assert!(ids.contains(&0), "Should contain row 0 (apple)");
3965                assert!(ids.contains(&1), "Should contain row 1 (app)");
3966                assert!(ids.contains(&2), "Should contain row 2 (application)");
3967                assert!(!ids.contains(&3), "Should not contain row 3 (banana)");
3968            }
3969            _ => panic!("Expected Exact result"),
3970        }
3971    }
3972
3973    #[tokio::test]
3974    async fn test_fragment_btree_index_consistency() {
3975        // Setup stores for both indexes
3976        let full_tmpdir = TempObjDir::default();
3977        let full_store = Arc::new(LanceIndexStore::new(
3978            Arc::new(ObjectStore::local()),
3979            full_tmpdir.clone(),
3980            Arc::new(LanceCache::no_cache()),
3981        ));
3982
3983        let fragment_tmpdir = TempObjDir::default();
3984        let fragment_store = Arc::new(LanceIndexStore::new(
3985            Arc::new(ObjectStore::local()),
3986            fragment_tmpdir.clone(),
3987            Arc::new(LanceCache::no_cache()),
3988        ));
3989
3990        // Method 1: Build complete index directly using the same data
3991        // Create deterministic data for comparison - use 2 * DEFAULT_BTREE_BATCH_SIZE for testing
3992        let total_count = 2 * DEFAULT_BTREE_BATCH_SIZE;
3993        let full_data_gen = gen_batch()
3994            .col("value", array::step::<Int32Type>())
3995            .col("_rowid", array::step::<UInt64Type>())
3996            .into_df_stream(RowCount::from(total_count / 2), BatchCount::from(2));
3997        let full_data_source = Box::pin(RecordBatchStreamAdapter::new(
3998            full_data_gen.schema(),
3999            full_data_gen,
4000        ));
4001
4002        train_btree_index(
4003            full_data_source,
4004            full_store.as_ref(),
4005            DEFAULT_BTREE_BATCH_SIZE,
4006            None,
4007            None,
4008        )
4009        .await
4010        .unwrap();
4011
4012        // Method 2: Build fragment-based index using the same data split into fragments
4013        // Create fragment 1 index - first half of the data (0 to DEFAULT_BTREE_BATCH_SIZE-1)
4014        let half_count = DEFAULT_BTREE_BATCH_SIZE;
4015        let fragment1_gen = gen_batch()
4016            .col("value", array::step::<Int32Type>())
4017            .col("_rowid", array::step::<UInt64Type>())
4018            .into_df_stream(RowCount::from(half_count), BatchCount::from(1));
4019        let fragment1_data_source = Box::pin(RecordBatchStreamAdapter::new(
4020            fragment1_gen.schema(),
4021            fragment1_gen,
4022        ));
4023
4024        train_btree_index(
4025            fragment1_data_source,
4026            fragment_store.as_ref(),
4027            DEFAULT_BTREE_BATCH_SIZE,
4028            Some(vec![1]), // fragment_id = 1
4029            None,
4030        )
4031        .await
4032        .unwrap();
4033
4034        // Create fragment 2 index - second half of the data (DEFAULT_BTREE_BATCH_SIZE to 2*DEFAULT_BTREE_BATCH_SIZE-1)
4035        let start_val = DEFAULT_BTREE_BATCH_SIZE as i32;
4036        let end_val = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4037        let values_second_half: Vec<i32> = (start_val..end_val).collect();
4038        let row_ids_second_half: Vec<u64> = (start_val as u64..end_val as u64).collect();
4039        let fragment2_gen = gen_batch()
4040            .col("value", array::cycle::<Int32Type>(values_second_half))
4041            .col("_rowid", array::cycle::<UInt64Type>(row_ids_second_half))
4042            .into_df_stream(RowCount::from(half_count), BatchCount::from(1));
4043        let fragment2_data_source = Box::pin(RecordBatchStreamAdapter::new(
4044            fragment2_gen.schema(),
4045            fragment2_gen,
4046        ));
4047
4048        train_btree_index(
4049            fragment2_data_source,
4050            fragment_store.as_ref(),
4051            DEFAULT_BTREE_BATCH_SIZE,
4052            Some(vec![2]), // fragment_id = 2
4053            None,
4054        )
4055        .await
4056        .unwrap();
4057
4058        // Merge the fragment files
4059        let part_page_files = vec![
4060            part_page_data_file_path(1 << 32),
4061            part_page_data_file_path(2 << 32),
4062        ];
4063
4064        let part_lookup_files = vec![
4065            part_lookup_file_path(1 << 32),
4066            part_lookup_file_path(2 << 32),
4067        ];
4068
4069        let progress = Arc::new(RecordingProgress::default());
4070        super::merge_metadata_files(
4071            fragment_store.as_ref(),
4072            &part_page_files,
4073            &part_lookup_files,
4074            Option::from(1usize),
4075            progress.clone(),
4076        )
4077        .await
4078        .unwrap();
4079
4080        let tags = progress
4081            .recorded_events()
4082            .iter()
4083            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
4084            .collect::<Vec<_>>();
4085        let merge_start = tags
4086            .iter()
4087            .position(|e| e == "start:merge_pages")
4088            .expect("missing merge_pages start");
4089        let merge_complete = tags
4090            .iter()
4091            .position(|e| e == "complete:merge_pages")
4092            .expect("missing merge_pages complete");
4093        let lookup_start = tags
4094            .iter()
4095            .position(|e| e == "start:write_lookup_file")
4096            .expect("missing write_lookup_file start");
4097        let lookup_complete = tags
4098            .iter()
4099            .position(|e| e == "complete:write_lookup_file")
4100            .expect("missing write_lookup_file complete");
4101        assert!(merge_start < merge_complete);
4102        assert!(merge_complete < lookup_start);
4103        assert!(lookup_start < lookup_complete);
4104        assert!(
4105            tags.iter().any(|e| e == "progress:merge_pages"),
4106            "expected merge_pages progress callbacks"
4107        );
4108        assert!(
4109            tags.iter().any(|e| e == "progress:write_lookup_file"),
4110            "expected write_lookup_file progress callbacks"
4111        );
4112
4113        // Load both indexes
4114        let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache())
4115            .await
4116            .unwrap();
4117
4118        let merged_index = BTreeIndex::load(fragment_store.clone(), None, &LanceCache::no_cache())
4119            .await
4120            .unwrap();
4121
4122        // Test queries one by one to identify the exact problem
4123
4124        // Test 1: Query for value 0 (should be in first page)
4125        let query_0 = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
4126        let full_result_0 = full_index
4127            .search(&query_0, &NoOpMetricsCollector)
4128            .await
4129            .unwrap();
4130        let merged_result_0 = merged_index
4131            .search(&query_0, &NoOpMetricsCollector)
4132            .await
4133            .unwrap();
4134        assert_eq!(full_result_0, merged_result_0, "Query for value 0 failed");
4135
4136        // Test 2: Query for value in middle of first batch (should be in first page)
4137        let mid_first_batch = (DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4138        let query_mid_first = SargableQuery::Equals(ScalarValue::Int32(Some(mid_first_batch)));
4139        let full_result_mid_first = full_index
4140            .search(&query_mid_first, &NoOpMetricsCollector)
4141            .await
4142            .unwrap();
4143        let merged_result_mid_first = merged_index
4144            .search(&query_mid_first, &NoOpMetricsCollector)
4145            .await
4146            .unwrap();
4147        assert_eq!(
4148            full_result_mid_first, merged_result_mid_first,
4149            "Query for value {} failed",
4150            mid_first_batch
4151        );
4152
4153        // Test 3: Query for first value in second batch (should be in second page)
4154        let first_second_batch = DEFAULT_BTREE_BATCH_SIZE as i32;
4155        let query_first_second =
4156            SargableQuery::Equals(ScalarValue::Int32(Some(first_second_batch)));
4157        let full_result_first_second = full_index
4158            .search(&query_first_second, &NoOpMetricsCollector)
4159            .await
4160            .unwrap();
4161        let merged_result_first_second = merged_index
4162            .search(&query_first_second, &NoOpMetricsCollector)
4163            .await
4164            .unwrap();
4165        assert_eq!(
4166            full_result_first_second, merged_result_first_second,
4167            "Query for value {} failed",
4168            first_second_batch
4169        );
4170
4171        // Test 4: Query for value in middle of second batch (should be in second page)
4172        let mid_second_batch = (DEFAULT_BTREE_BATCH_SIZE + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4173        let query_mid_second = SargableQuery::Equals(ScalarValue::Int32(Some(mid_second_batch)));
4174
4175        let full_result_mid_second = full_index
4176            .search(&query_mid_second, &NoOpMetricsCollector)
4177            .await
4178            .unwrap();
4179        let merged_result_mid_second = merged_index
4180            .search(&query_mid_second, &NoOpMetricsCollector)
4181            .await
4182            .unwrap();
4183        assert_eq!(
4184            full_result_mid_second, merged_result_mid_second,
4185            "Query for value {} failed",
4186            mid_second_batch
4187        );
4188    }
4189
4190    #[tokio::test]
4191    async fn test_fragment_btree_index_boundary_queries() {
4192        // Setup stores for both indexes
4193        let full_tmpdir = TempObjDir::default();
4194        let full_store = Arc::new(LanceIndexStore::new(
4195            Arc::new(ObjectStore::local()),
4196            full_tmpdir.clone(),
4197            Arc::new(LanceCache::no_cache()),
4198        ));
4199
4200        let fragment_tmpdir = TempObjDir::default();
4201        let fragment_store = Arc::new(LanceIndexStore::new(
4202            Arc::new(ObjectStore::local()),
4203            fragment_tmpdir.clone(),
4204            Arc::new(LanceCache::no_cache()),
4205        ));
4206
4207        // Use 3 * DEFAULT_BTREE_BATCH_SIZE for more comprehensive boundary testing
4208        let total_count = 3 * DEFAULT_BTREE_BATCH_SIZE;
4209
4210        // Method 1: Build complete index directly
4211        let full_data_gen = gen_batch()
4212            .col("value", array::step::<Int32Type>())
4213            .col("_rowid", array::step::<UInt64Type>())
4214            .into_df_stream(RowCount::from(total_count / 3), BatchCount::from(3));
4215        let full_data_source = Box::pin(RecordBatchStreamAdapter::new(
4216            full_data_gen.schema(),
4217            full_data_gen,
4218        ));
4219
4220        train_btree_index(
4221            full_data_source,
4222            full_store.as_ref(),
4223            DEFAULT_BTREE_BATCH_SIZE,
4224            None,
4225            None,
4226        )
4227        .await
4228        .unwrap();
4229
4230        // Method 2: Build fragment-based index using 3 fragments
4231        // Fragment 1: 0 to DEFAULT_BTREE_BATCH_SIZE-1
4232        let fragment_size = DEFAULT_BTREE_BATCH_SIZE;
4233        let fragment1_gen = gen_batch()
4234            .col("value", array::step::<Int32Type>())
4235            .col("_rowid", array::step::<UInt64Type>())
4236            .into_df_stream(RowCount::from(fragment_size), BatchCount::from(1));
4237        let fragment1_data_source = Box::pin(RecordBatchStreamAdapter::new(
4238            fragment1_gen.schema(),
4239            fragment1_gen,
4240        ));
4241
4242        train_btree_index(
4243            fragment1_data_source,
4244            fragment_store.as_ref(),
4245            DEFAULT_BTREE_BATCH_SIZE,
4246            Some(vec![1]),
4247            None,
4248        )
4249        .await
4250        .unwrap();
4251
4252        // Fragment 2: DEFAULT_BTREE_BATCH_SIZE to 2*DEFAULT_BTREE_BATCH_SIZE-1
4253        let start_val2 = DEFAULT_BTREE_BATCH_SIZE as i32;
4254        let end_val2 = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4255        let values_fragment2: Vec<i32> = (start_val2..end_val2).collect();
4256        let row_ids_fragment2: Vec<u64> = (start_val2 as u64..end_val2 as u64).collect();
4257        let fragment2_gen = gen_batch()
4258            .col("value", array::cycle::<Int32Type>(values_fragment2))
4259            .col("_rowid", array::cycle::<UInt64Type>(row_ids_fragment2))
4260            .into_df_stream(RowCount::from(fragment_size), BatchCount::from(1));
4261        let fragment2_data_source = Box::pin(RecordBatchStreamAdapter::new(
4262            fragment2_gen.schema(),
4263            fragment2_gen,
4264        ));
4265
4266        train_btree_index(
4267            fragment2_data_source,
4268            fragment_store.as_ref(),
4269            DEFAULT_BTREE_BATCH_SIZE,
4270            Some(vec![2]),
4271            None,
4272        )
4273        .await
4274        .unwrap();
4275
4276        // Fragment 3: 2*DEFAULT_BTREE_BATCH_SIZE to 3*DEFAULT_BTREE_BATCH_SIZE-1
4277        let start_val3 = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4278        let end_val3 = (3 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4279        let values_fragment3: Vec<i32> = (start_val3..end_val3).collect();
4280        let row_ids_fragment3: Vec<u64> = (start_val3 as u64..end_val3 as u64).collect();
4281        let fragment3_gen = gen_batch()
4282            .col("value", array::cycle::<Int32Type>(values_fragment3))
4283            .col("_rowid", array::cycle::<UInt64Type>(row_ids_fragment3))
4284            .into_df_stream(RowCount::from(fragment_size), BatchCount::from(1));
4285        let fragment3_data_source = Box::pin(RecordBatchStreamAdapter::new(
4286            fragment3_gen.schema(),
4287            fragment3_gen,
4288        ));
4289
4290        train_btree_index(
4291            fragment3_data_source,
4292            fragment_store.as_ref(),
4293            DEFAULT_BTREE_BATCH_SIZE,
4294            Some(vec![3]),
4295            None,
4296        )
4297        .await
4298        .unwrap();
4299
4300        // Merge all fragment files
4301        let part_page_files = vec![
4302            part_page_data_file_path(1 << 32),
4303            part_page_data_file_path(2 << 32),
4304            part_page_data_file_path(3 << 32),
4305        ];
4306
4307        let part_lookup_files = vec![
4308            part_lookup_file_path(1 << 32),
4309            part_lookup_file_path(2 << 32),
4310            part_lookup_file_path(3 << 32),
4311        ];
4312
4313        super::merge_metadata_files(
4314            fragment_store.as_ref(),
4315            &part_page_files,
4316            &part_lookup_files,
4317            Option::from(1usize),
4318            noop_progress(),
4319        )
4320        .await
4321        .unwrap();
4322
4323        // Load both indexes
4324        let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache())
4325            .await
4326            .unwrap();
4327
4328        let merged_index = BTreeIndex::load(fragment_store.clone(), None, &LanceCache::no_cache())
4329            .await
4330            .unwrap();
4331
4332        // === Boundary Value Tests ===
4333
4334        // Test 1: Query minimum value (boundary: data start)
4335        let query_min = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
4336        let full_result_min = full_index
4337            .search(&query_min, &NoOpMetricsCollector)
4338            .await
4339            .unwrap();
4340        let merged_result_min = merged_index
4341            .search(&query_min, &NoOpMetricsCollector)
4342            .await
4343            .unwrap();
4344        assert_eq!(
4345            full_result_min, merged_result_min,
4346            "Query for minimum value 0 failed"
4347        );
4348
4349        // Test 2: Query maximum value (boundary: data end)
4350        let max_val = (3 * DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
4351        let query_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val)));
4352        let full_result_max = full_index
4353            .search(&query_max, &NoOpMetricsCollector)
4354            .await
4355            .unwrap();
4356        let merged_result_max = merged_index
4357            .search(&query_max, &NoOpMetricsCollector)
4358            .await
4359            .unwrap();
4360        assert_eq!(
4361            full_result_max, merged_result_max,
4362            "Query for maximum value {} failed",
4363            max_val
4364        );
4365
4366        // Test 3: Query fragment boundary value (last value of first fragment)
4367        let fragment1_last = (DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
4368        let query_frag1_last = SargableQuery::Equals(ScalarValue::Int32(Some(fragment1_last)));
4369        let full_result_frag1_last = full_index
4370            .search(&query_frag1_last, &NoOpMetricsCollector)
4371            .await
4372            .unwrap();
4373        let merged_result_frag1_last = merged_index
4374            .search(&query_frag1_last, &NoOpMetricsCollector)
4375            .await
4376            .unwrap();
4377        assert_eq!(
4378            full_result_frag1_last, merged_result_frag1_last,
4379            "Query for fragment 1 last value {} failed",
4380            fragment1_last
4381        );
4382
4383        // Test 4: Query fragment boundary value (first value of second fragment)
4384        let fragment2_first = DEFAULT_BTREE_BATCH_SIZE as i32;
4385        let query_frag2_first = SargableQuery::Equals(ScalarValue::Int32(Some(fragment2_first)));
4386        let full_result_frag2_first = full_index
4387            .search(&query_frag2_first, &NoOpMetricsCollector)
4388            .await
4389            .unwrap();
4390        let merged_result_frag2_first = merged_index
4391            .search(&query_frag2_first, &NoOpMetricsCollector)
4392            .await
4393            .unwrap();
4394        assert_eq!(
4395            full_result_frag2_first, merged_result_frag2_first,
4396            "Query for fragment 2 first value {} failed",
4397            fragment2_first
4398        );
4399
4400        // Test 5: Query fragment boundary value (last value of second fragment)
4401        let fragment2_last = (2 * DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
4402        let query_frag2_last = SargableQuery::Equals(ScalarValue::Int32(Some(fragment2_last)));
4403        let full_result_frag2_last = full_index
4404            .search(&query_frag2_last, &NoOpMetricsCollector)
4405            .await
4406            .unwrap();
4407        let merged_result_frag2_last = merged_index
4408            .search(&query_frag2_last, &NoOpMetricsCollector)
4409            .await
4410            .unwrap();
4411        assert_eq!(
4412            full_result_frag2_last, merged_result_frag2_last,
4413            "Query for fragment 2 last value {} failed",
4414            fragment2_last
4415        );
4416
4417        // Test 6: Query fragment boundary value (first value of third fragment)
4418        let fragment3_first = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4419        let query_frag3_first = SargableQuery::Equals(ScalarValue::Int32(Some(fragment3_first)));
4420        let full_result_frag3_first = full_index
4421            .search(&query_frag3_first, &NoOpMetricsCollector)
4422            .await
4423            .unwrap();
4424        let merged_result_frag3_first = merged_index
4425            .search(&query_frag3_first, &NoOpMetricsCollector)
4426            .await
4427            .unwrap();
4428        assert_eq!(
4429            full_result_frag3_first, merged_result_frag3_first,
4430            "Query for fragment 3 first value {} failed",
4431            fragment3_first
4432        );
4433
4434        // === Non-existent Value Tests ===
4435
4436        // Test 7: Query value below minimum
4437        let query_below_min = SargableQuery::Equals(ScalarValue::Int32(Some(-1)));
4438        let full_result_below = full_index
4439            .search(&query_below_min, &NoOpMetricsCollector)
4440            .await
4441            .unwrap();
4442        let merged_result_below = merged_index
4443            .search(&query_below_min, &NoOpMetricsCollector)
4444            .await
4445            .unwrap();
4446        assert_eq!(
4447            full_result_below, merged_result_below,
4448            "Query for value below minimum (-1) failed"
4449        );
4450
4451        // Test 8: Query value above maximum
4452        let query_above_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val + 1)));
4453        let full_result_above = full_index
4454            .search(&query_above_max, &NoOpMetricsCollector)
4455            .await
4456            .unwrap();
4457        let merged_result_above = merged_index
4458            .search(&query_above_max, &NoOpMetricsCollector)
4459            .await
4460            .unwrap();
4461        assert_eq!(
4462            full_result_above,
4463            merged_result_above,
4464            "Query for value above maximum ({}) failed",
4465            max_val + 1
4466        );
4467
4468        // === Range Query Tests ===
4469
4470        // Test 9: Cross-fragment range query (from first fragment to second fragment)
4471        let range_start = (DEFAULT_BTREE_BATCH_SIZE - 100) as i32;
4472        let range_end = (DEFAULT_BTREE_BATCH_SIZE + 100) as i32;
4473        let query_cross_frag = SargableQuery::Range(
4474            std::collections::Bound::Included(ScalarValue::Int32(Some(range_start))),
4475            std::collections::Bound::Excluded(ScalarValue::Int32(Some(range_end))),
4476        );
4477        let full_result_cross = full_index
4478            .search(&query_cross_frag, &NoOpMetricsCollector)
4479            .await
4480            .unwrap();
4481        let merged_result_cross = merged_index
4482            .search(&query_cross_frag, &NoOpMetricsCollector)
4483            .await
4484            .unwrap();
4485        assert_eq!(
4486            full_result_cross, merged_result_cross,
4487            "Cross-fragment range query [{}, {}] failed",
4488            range_start, range_end
4489        );
4490
4491        // Test 10: Range query within single fragment
4492        let single_frag_start = 100i32;
4493        let single_frag_end = 200i32;
4494        let query_single_frag = SargableQuery::Range(
4495            std::collections::Bound::Included(ScalarValue::Int32(Some(single_frag_start))),
4496            std::collections::Bound::Excluded(ScalarValue::Int32(Some(single_frag_end))),
4497        );
4498        let full_result_single = full_index
4499            .search(&query_single_frag, &NoOpMetricsCollector)
4500            .await
4501            .unwrap();
4502        let merged_result_single = merged_index
4503            .search(&query_single_frag, &NoOpMetricsCollector)
4504            .await
4505            .unwrap();
4506        assert_eq!(
4507            full_result_single, merged_result_single,
4508            "Single fragment range query [{}, {}] failed",
4509            single_frag_start, single_frag_end
4510        );
4511
4512        // Test 11: Large range query spanning all fragments
4513        let large_range_start = 100i32;
4514        let large_range_end = (3 * DEFAULT_BTREE_BATCH_SIZE - 100) as i32;
4515        let query_large_range = SargableQuery::Range(
4516            std::collections::Bound::Included(ScalarValue::Int32(Some(large_range_start))),
4517            std::collections::Bound::Excluded(ScalarValue::Int32(Some(large_range_end))),
4518        );
4519        let full_result_large = full_index
4520            .search(&query_large_range, &NoOpMetricsCollector)
4521            .await
4522            .unwrap();
4523        let merged_result_large = merged_index
4524            .search(&query_large_range, &NoOpMetricsCollector)
4525            .await
4526            .unwrap();
4527        assert_eq!(
4528            full_result_large, merged_result_large,
4529            "Large range query [{}, {}] failed",
4530            large_range_start, large_range_end
4531        );
4532
4533        // === Range Boundary Query Tests ===
4534
4535        // Test 12: Less than query (implemented using range query, from minimum to specified value)
4536        let lt_val = (DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4537        let query_lt = SargableQuery::Range(
4538            std::collections::Bound::Included(ScalarValue::Int32(Some(0))),
4539            std::collections::Bound::Excluded(ScalarValue::Int32(Some(lt_val))),
4540        );
4541        let full_result_lt = full_index
4542            .search(&query_lt, &NoOpMetricsCollector)
4543            .await
4544            .unwrap();
4545        let merged_result_lt = merged_index
4546            .search(&query_lt, &NoOpMetricsCollector)
4547            .await
4548            .unwrap();
4549        assert_eq!(
4550            full_result_lt, merged_result_lt,
4551            "Less than query (<{}) failed",
4552            lt_val
4553        );
4554
4555        // Test 13: Greater than query (implemented using range query, from specified value to maximum)
4556        let gt_val = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4557        let max_range_val = (3 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4558        let query_gt = SargableQuery::Range(
4559            std::collections::Bound::Excluded(ScalarValue::Int32(Some(gt_val))),
4560            std::collections::Bound::Excluded(ScalarValue::Int32(Some(max_range_val))),
4561        );
4562        let full_result_gt = full_index
4563            .search(&query_gt, &NoOpMetricsCollector)
4564            .await
4565            .unwrap();
4566        let merged_result_gt = merged_index
4567            .search(&query_gt, &NoOpMetricsCollector)
4568            .await
4569            .unwrap();
4570        assert_eq!(
4571            full_result_gt, merged_result_gt,
4572            "Greater than query (>{}) failed",
4573            gt_val
4574        );
4575
4576        // Test 14: Less than or equal query (implemented using range query, including boundary value)
4577        let lte_val = (DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
4578        let query_lte = SargableQuery::Range(
4579            std::collections::Bound::Included(ScalarValue::Int32(Some(0))),
4580            std::collections::Bound::Included(ScalarValue::Int32(Some(lte_val))),
4581        );
4582        let full_result_lte = full_index
4583            .search(&query_lte, &NoOpMetricsCollector)
4584            .await
4585            .unwrap();
4586        let merged_result_lte = merged_index
4587            .search(&query_lte, &NoOpMetricsCollector)
4588            .await
4589            .unwrap();
4590        assert_eq!(
4591            full_result_lte, merged_result_lte,
4592            "Less than or equal query (<={}) failed",
4593            lte_val
4594        );
4595
4596        // Test 15: Greater than or equal query (implemented using range query, including boundary value)
4597        let gte_val = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4598        let query_gte = SargableQuery::Range(
4599            std::collections::Bound::Included(ScalarValue::Int32(Some(gte_val))),
4600            std::collections::Bound::Excluded(ScalarValue::Int32(Some(max_range_val))),
4601        );
4602        let full_result_gte = full_index
4603            .search(&query_gte, &NoOpMetricsCollector)
4604            .await
4605            .unwrap();
4606        let merged_result_gte = merged_index
4607            .search(&query_gte, &NoOpMetricsCollector)
4608            .await
4609            .unwrap();
4610        assert_eq!(
4611            full_result_gte, merged_result_gte,
4612            "Greater than or equal query (>={}) failed",
4613            gte_val
4614        );
4615    }
4616
4617    #[test]
4618    fn test_extract_partition_id() {
4619        // Test valid partition file names
4620        assert_eq!(
4621            super::extract_partition_id("part_123_page_data.lance").unwrap(),
4622            123
4623        );
4624        assert_eq!(
4625            super::extract_partition_id("part_456_page_lookup.lance").unwrap(),
4626            456
4627        );
4628        assert_eq!(
4629            super::extract_partition_id("part_4294967296_page_data.lance").unwrap(),
4630            4294967296
4631        );
4632
4633        // Test invalid file names
4634        assert!(super::extract_partition_id("invalid_filename.lance").is_err());
4635        assert!(super::extract_partition_id("part_abc_page_data.lance").is_err());
4636        assert!(super::extract_partition_id("part_123").is_err());
4637        assert!(super::extract_partition_id("part_").is_err());
4638    }
4639
4640    #[tokio::test]
4641    async fn test_cleanup_partition_files() {
4642        // Create a test store
4643        let tmpdir = TempObjDir::default();
4644        let test_store: Arc<dyn crate::scalar::IndexStore> = Arc::new(LanceIndexStore::new(
4645            Arc::new(ObjectStore::local()),
4646            tmpdir.clone(),
4647            Arc::new(LanceCache::no_cache()),
4648        ));
4649
4650        // Test files with different patterns
4651        let lookup_files = vec![
4652            "part_123_page_lookup.lance".to_string(),
4653            "invalid_lookup_file.lance".to_string(),
4654            "part_456_page_lookup.lance".to_string(),
4655        ];
4656
4657        let page_files = vec![
4658            "part_123_page_data.lance".to_string(),
4659            "invalid_page_file.lance".to_string(),
4660            "part_456_page_data.lance".to_string(),
4661        ];
4662
4663        // The cleanup function should handle both valid and invalid file patterns gracefully
4664        // This test mainly verifies that the function doesn't panic and handles edge cases
4665        super::cleanup_partition_files(test_store.as_ref(), &lookup_files, &page_files).await;
4666    }
4667
4668    #[tokio::test]
4669    async fn test_btree_null_handling_in_queries() {
4670        let store = Arc::new(LanceIndexStore::new(
4671            Arc::new(ObjectStore::memory()),
4672            Path::default(),
4673            Arc::new(LanceCache::no_cache()),
4674        ));
4675
4676        // Create test data: [null, 0, 5] at row IDs [0, 1, 2]
4677        // BTree expects sorted data with nulls first (or filtered out)
4678        let batch = record_batch!(
4679            ("value", Int32, [None, Some(0), Some(5)]),
4680            ("_rowid", UInt64, [0, 1, 2])
4681        )
4682        .unwrap();
4683        let stream = stream::once(futures::future::ok(batch.clone()));
4684        let stream = Box::pin(RecordBatchStreamAdapter::new(batch.schema(), stream));
4685
4686        // Train the btree index with FlatIndexMetadata as sub-index
4687        super::train_btree_index(stream, store.as_ref(), 256, None, None)
4688            .await
4689            .unwrap();
4690
4691        let cache = LanceCache::with_capacity(1024 * 1024);
4692        let index = super::BTreeIndex::load(store.clone(), None, &cache)
4693            .await
4694            .unwrap();
4695
4696        // Test 1: Search for value 5 - should return allow=[2], null=[0]
4697        let query = SargableQuery::Equals(ScalarValue::Int32(Some(5)));
4698        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
4699
4700        match result {
4701            SearchResult::Exact(row_ids) => {
4702                let actual_rows: Vec<u64> = row_ids
4703                    .true_rows()
4704                    .row_addrs()
4705                    .unwrap()
4706                    .map(u64::from)
4707                    .collect();
4708                assert_eq!(actual_rows, vec![2], "Should find row 2 where value == 5");
4709
4710                // Check that null_row_ids contains row 0
4711                let null_row_ids = row_ids.null_rows();
4712                assert!(!null_row_ids.is_empty(), "null_row_ids should be non-empty");
4713                let null_rows: Vec<u64> =
4714                    null_row_ids.row_addrs().unwrap().map(u64::from).collect();
4715                assert_eq!(null_rows, vec![0], "Should report row 0 as null");
4716            }
4717            _ => panic!("Expected Exact search result"),
4718        }
4719
4720        // Test 2: Range query [0, 3] - should return allow=[1], null=[0]
4721        let query = SargableQuery::Range(
4722            std::ops::Bound::Included(ScalarValue::Int32(Some(0))),
4723            std::ops::Bound::Included(ScalarValue::Int32(Some(3))),
4724        );
4725        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
4726
4727        match result {
4728            SearchResult::Exact(row_ids) => {
4729                let actual_rows: Vec<u64> = row_ids
4730                    .true_rows()
4731                    .row_addrs()
4732                    .unwrap()
4733                    .map(u64::from)
4734                    .collect();
4735                assert_eq!(actual_rows, vec![1], "Should find row 1 where value == 0");
4736
4737                // Should report row 0 as null
4738                let null_row_ids = row_ids.null_rows();
4739                assert!(!null_row_ids.is_empty(), "null_row_ids should be non-empty");
4740                let null_rows: Vec<u64> =
4741                    null_row_ids.row_addrs().unwrap().map(u64::from).collect();
4742                assert_eq!(null_rows, vec![0], "Should report row 0 as null");
4743            }
4744            _ => panic!("Expected Exact search result"),
4745        }
4746
4747        // Test 3: IsIn query [0, 5] - should return allow=[1, 2], null=[0]
4748        let query = SargableQuery::IsIn(vec![
4749            ScalarValue::Int32(Some(0)),
4750            ScalarValue::Int32(Some(5)),
4751        ]);
4752        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
4753
4754        match result {
4755            SearchResult::Exact(row_ids) => {
4756                let mut actual_rows: Vec<u64> = row_ids
4757                    .true_rows()
4758                    .row_addrs()
4759                    .unwrap()
4760                    .map(u64::from)
4761                    .collect();
4762                actual_rows.sort();
4763                assert_eq!(
4764                    actual_rows,
4765                    vec![1, 2],
4766                    "Should find rows 1 and 2 where value in [0, 5]"
4767                );
4768
4769                // Should report row 0 as null
4770                let null_row_ids = row_ids.null_rows();
4771                assert!(!null_row_ids.is_empty(), "null_row_ids should be non-empty");
4772                let null_rows: Vec<u64> =
4773                    null_row_ids.row_addrs().unwrap().map(u64::from).collect();
4774                assert_eq!(null_rows, vec![0], "Should report row 0 as null");
4775            }
4776            _ => panic!("Expected Exact search result"),
4777        }
4778    }
4779
4780    #[tokio::test]
4781    async fn test_range_btree_index_consistency() {
4782        // Setup stores for both indexes
4783        let full_tmpdir = TempObjDir::default();
4784        let full_store = Arc::new(LanceIndexStore::new(
4785            Arc::new(ObjectStore::local()),
4786            full_tmpdir.clone(),
4787            Arc::new(LanceCache::no_cache()),
4788        ));
4789
4790        let range_tmpdir = TempObjDir::default();
4791        let range_store = Arc::new(LanceIndexStore::new(
4792            Arc::new(ObjectStore::local()),
4793            range_tmpdir.clone(),
4794            Arc::new(LanceCache::no_cache()),
4795        ));
4796
4797        // Method 1: Build complete index directly using the same data
4798        // Create deterministic data for comparison - use 4 * DEFAULT_BTREE_BATCH_SIZE for testing
4799        let total_count = 4 * DEFAULT_BTREE_BATCH_SIZE;
4800        let full_data_gen = gen_batch()
4801            .col("value", array::step::<Int32Type>())
4802            .col("_rowid", array::step::<UInt64Type>())
4803            .into_df_stream(RowCount::from(total_count / 4), BatchCount::from(4));
4804        let full_data_source = Box::pin(RecordBatchStreamAdapter::new(
4805            full_data_gen.schema(),
4806            full_data_gen,
4807        ));
4808
4809        train_btree_index(
4810            full_data_source,
4811            full_store.as_ref(),
4812            DEFAULT_BTREE_BATCH_SIZE,
4813            None,
4814            None,
4815        )
4816        .await
4817        .unwrap();
4818
4819        // Method 2: Build range-based index using the same data split into ranges
4820        // Create range 1 index, intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
4821        let range1_gen = gen_batch()
4822            .col("value", array::step::<Int32Type>())
4823            .col("_rowid", array::step::<UInt64Type>())
4824            .into_df_stream(
4825                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4826                BatchCount::from(5),
4827            );
4828        let range1_data_source = Box::pin(RecordBatchStreamAdapter::new(
4829            range1_gen.schema(),
4830            range1_gen,
4831        ));
4832
4833        train_btree_index(
4834            range1_data_source,
4835            range_store.as_ref(),
4836            DEFAULT_BTREE_BATCH_SIZE,
4837            None,
4838            Option::from(0u32),
4839        )
4840        .await
4841        .unwrap();
4842
4843        // Create range 2 index, also intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
4844        let start_val = (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4845        let end_val = (4 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4846        let values_second_half: Vec<i32> = (start_val..end_val).collect();
4847        let row_ids_second_half: Vec<u64> = (start_val as u64..end_val as u64).collect();
4848        let range2_gen = gen_batch()
4849            .col("value", array::cycle::<Int32Type>(values_second_half))
4850            .col("_rowid", array::cycle::<UInt64Type>(row_ids_second_half))
4851            .into_df_stream(
4852                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4853                BatchCount::from(3),
4854            );
4855        let range2_data_source = Box::pin(RecordBatchStreamAdapter::new(
4856            range2_gen.schema(),
4857            range2_gen,
4858        ));
4859
4860        train_btree_index(
4861            range2_data_source,
4862            range_store.as_ref(),
4863            DEFAULT_BTREE_BATCH_SIZE,
4864            None,
4865            Option::from(1u32),
4866        )
4867        .await
4868        .unwrap();
4869
4870        // Merge the fragment files
4871        let part_page_files = vec![
4872            part_page_data_file_path(0 << 32),
4873            part_page_data_file_path(1 << 32),
4874        ];
4875
4876        let part_lookup_files = vec![
4877            part_lookup_file_path(0 << 32),
4878            part_lookup_file_path(1 << 32),
4879        ];
4880
4881        super::merge_metadata_files(
4882            range_store.as_ref(),
4883            &part_page_files,
4884            &part_lookup_files,
4885            Option::from(1usize),
4886            noop_progress(),
4887        )
4888        .await
4889        .unwrap();
4890
4891        let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache())
4892            .await
4893            .unwrap();
4894
4895        let ranged_index = BTreeIndex::load(range_store.clone(), None, &LanceCache::no_cache())
4896            .await
4897            .unwrap();
4898
4899        // Equality Tests
4900
4901        // Test 1: Query for value 0
4902        let query_0 = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
4903        let full_result_0 = full_index
4904            .search(&query_0, &NoOpMetricsCollector)
4905            .await
4906            .unwrap();
4907        let ranged_result_0 = ranged_index
4908            .search(&query_0, &NoOpMetricsCollector)
4909            .await
4910            .unwrap();
4911        assert_eq!(full_result_0, ranged_result_0, "Query for value 0 failed");
4912
4913        // Test 2: Query for value in middle of first batch (should be in first page)
4914        let mid_first_batch = (DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4915        let query_mid_first = SargableQuery::Equals(ScalarValue::Int32(Some(mid_first_batch)));
4916        let full_result_mid_first = full_index
4917            .search(&query_mid_first, &NoOpMetricsCollector)
4918            .await
4919            .unwrap();
4920        let ranged_result_mid_first = ranged_index
4921            .search(&query_mid_first, &NoOpMetricsCollector)
4922            .await
4923            .unwrap();
4924        assert_eq!(
4925            full_result_mid_first, ranged_result_mid_first,
4926            "Query for value {} failed",
4927            mid_first_batch
4928        );
4929
4930        // Test 3: Query for value in the last batch (should be in the second range file)
4931        let mid_last_batch = (DEFAULT_BTREE_BATCH_SIZE * 3 + (DEFAULT_BTREE_BATCH_SIZE / 2)) as i32;
4932        let query_mid_last = SargableQuery::Equals(ScalarValue::Int32(Some(mid_last_batch)));
4933        let full_result_mid_last = full_index
4934            .search(&query_mid_last, &NoOpMetricsCollector)
4935            .await
4936            .unwrap();
4937        let ranged_result_mid_last = ranged_index
4938            .search(&query_mid_last, &NoOpMetricsCollector)
4939            .await
4940            .unwrap();
4941        assert_eq!(
4942            full_result_mid_last, ranged_result_mid_last,
4943            "Query for value {} failed",
4944            mid_last_batch
4945        );
4946
4947        // Test 4: Query upper bound.
4948        let max_val = (4 * DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
4949        let query_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val)));
4950        let full_result_max = full_index
4951            .search(&query_max, &NoOpMetricsCollector)
4952            .await
4953            .unwrap();
4954        let ranged_result_max = ranged_index
4955            .search(&query_max, &NoOpMetricsCollector)
4956            .await
4957            .unwrap();
4958        assert_eq!(
4959            full_result_max, ranged_result_max,
4960            "Query for maximum value {} failed",
4961            max_val
4962        );
4963
4964        // Test 5: Query first value of the second page file.
4965        let second_first_val = (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4966        let query_second_first = SargableQuery::Equals(ScalarValue::Int32(Some(second_first_val)));
4967        let full_result_second_first = full_index
4968            .search(&query_second_first, &NoOpMetricsCollector)
4969            .await
4970            .unwrap();
4971        let ranged_result_second_first = ranged_index
4972            .search(&query_second_first, &NoOpMetricsCollector)
4973            .await
4974            .unwrap();
4975        assert_eq!(
4976            full_result_second_first, ranged_result_second_first,
4977            "Query for first value of the second page file {} failed",
4978            second_first_val
4979        );
4980
4981        // Test 6: Query value below the minimum
4982        let query_below_min = SargableQuery::Equals(ScalarValue::Int32(Some(-1)));
4983        let full_result_below = full_index
4984            .search(&query_below_min, &NoOpMetricsCollector)
4985            .await
4986            .unwrap();
4987        let ranged_result_below = ranged_index
4988            .search(&query_below_min, &NoOpMetricsCollector)
4989            .await
4990            .unwrap();
4991        assert_eq!(
4992            full_result_below, ranged_result_below,
4993            "Query for value below minimum (-1) failed"
4994        );
4995
4996        // Test 7: Query value above the maximum
4997        let query_above_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val + 1)));
4998        let full_result_above = full_index
4999            .search(&query_above_max, &NoOpMetricsCollector)
5000            .await
5001            .unwrap();
5002        let ranged_result_above = ranged_index
5003            .search(&query_above_max, &NoOpMetricsCollector)
5004            .await
5005            .unwrap();
5006        assert_eq!(
5007            full_result_above,
5008            ranged_result_above,
5009            "Query for value above maximum ({}) failed",
5010            max_val + 1
5011        );
5012
5013        // Range Tests
5014
5015        // Test 8: Cross-range query: One range including different values from adjacent range files.
5016        let range_start =
5017            (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2 - 100) as i32;
5018        let range_end = range_start + 200;
5019        let query_cross_range = SargableQuery::Range(
5020            std::collections::Bound::Included(ScalarValue::Int32(Some(range_start))),
5021            std::collections::Bound::Excluded(ScalarValue::Int32(Some(range_end))),
5022        );
5023        let full_result_cross = full_index
5024            .search(&query_cross_range, &NoOpMetricsCollector)
5025            .await
5026            .unwrap();
5027        let ranged_result_cross = ranged_index
5028            .search(&query_cross_range, &NoOpMetricsCollector)
5029            .await
5030            .unwrap();
5031        assert_eq!(
5032            full_result_cross, ranged_result_cross,
5033            "Cross-range range query [{}, {}] failed",
5034            range_start, range_end
5035        );
5036
5037        // Test 9 Test simple range within a single page file
5038        let single_range_start = (DEFAULT_BTREE_BATCH_SIZE * 4 - 300) as i32;
5039        let single_range_end = single_range_start + 200;
5040        let query_single_range = SargableQuery::Range(
5041            std::collections::Bound::Included(ScalarValue::Int32(Some(single_range_start))),
5042            std::collections::Bound::Excluded(ScalarValue::Int32(Some(single_range_end))),
5043        );
5044        let full_result_single = full_index
5045            .search(&query_single_range, &NoOpMetricsCollector)
5046            .await
5047            .unwrap();
5048        let ranged_result_single = ranged_index
5049            .search(&query_single_range, &NoOpMetricsCollector)
5050            .await
5051            .unwrap();
5052        assert_eq!(
5053            full_result_single, ranged_result_single,
5054            "Single range query [{}, {}] failed",
5055            single_range_start, single_range_end
5056        );
5057
5058        // Test 10: Large range query spanning almost all values
5059        let large_range_start = 100_i32;
5060        let large_range_end = (DEFAULT_BTREE_BATCH_SIZE * 4 - 100) as i32;
5061        let query_large_range = SargableQuery::Range(
5062            std::collections::Bound::Included(ScalarValue::Int32(Some(large_range_start))),
5063            std::collections::Bound::Excluded(ScalarValue::Int32(Some(large_range_end))),
5064        );
5065        let full_result_single = full_index
5066            .search(&query_large_range, &NoOpMetricsCollector)
5067            .await
5068            .unwrap();
5069        let ranged_result_single = ranged_index
5070            .search(&query_large_range, &NoOpMetricsCollector)
5071            .await
5072            .unwrap();
5073        assert_eq!(
5074            full_result_single, ranged_result_single,
5075            "Single fragment range query [{}, {}] failed",
5076            large_range_start, large_range_end
5077        );
5078
5079        let remap_dir = TempObjDir::default();
5080        let remap_store = Arc::new(LanceIndexStore::new(
5081            Arc::new(ObjectStore::local()),
5082            remap_dir.clone(),
5083            Arc::new(LanceCache::no_cache()),
5084        ));
5085
5086        // Remap with a no-op mapping.  The remapped index should be identical to the original
5087        ranged_index
5088            .remap(&RowAddrRemap::empty(), remap_store.as_ref())
5089            .await
5090            .unwrap();
5091
5092        let remap_index = BTreeIndex::load(remap_store.clone(), None, &LanceCache::no_cache())
5093            .await
5094            .unwrap();
5095
5096        assert_eq!(remap_index.page_lookup, ranged_index.page_lookup);
5097
5098        let ranged_pages = range_store
5099            .open_index_file(part_page_data_file_path(1 << 32).as_str())
5100            .await
5101            .unwrap();
5102        let remapped_pages = remap_store
5103            .open_index_file(part_page_data_file_path(1 << 32).as_str())
5104            .await
5105            .unwrap();
5106
5107        assert_eq!(ranged_pages.num_rows(), remapped_pages.num_rows());
5108
5109        let original_data = ranged_pages
5110            .read_record_batch(0, ranged_pages.num_rows() as u64)
5111            .await
5112            .unwrap();
5113        let remapped_data = remapped_pages
5114            .read_record_batch(0, remapped_pages.num_rows() as u64)
5115            .await
5116            .unwrap();
5117
5118        assert_eq!(original_data, remapped_data);
5119    }
5120
5121    #[tokio::test]
5122    async fn test_update_ranged_index() {
5123        // Setup stores for both indexes
5124        let old_tmpdir = TempObjDir::default();
5125        let old_store = Arc::new(LanceIndexStore::new(
5126            Arc::new(ObjectStore::local()),
5127            old_tmpdir.clone(),
5128            Arc::new(LanceCache::no_cache()),
5129        ));
5130
5131        let new_tmpdir = TempObjDir::default();
5132        let new_store = Arc::new(LanceIndexStore::new(
5133            Arc::new(ObjectStore::local()),
5134            new_tmpdir.clone(),
5135            Arc::new(LanceCache::no_cache()),
5136        ));
5137
5138        // Create range 1 index, intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
5139        let range1_gen = gen_batch()
5140            .col("value", array::step::<Int32Type>())
5141            .col("_rowid", array::step::<UInt64Type>())
5142            .into_df_stream(
5143                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
5144                BatchCount::from(5),
5145            );
5146        let range1_data_source = Box::pin(RecordBatchStreamAdapter::new(
5147            range1_gen.schema(),
5148            range1_gen,
5149        ));
5150
5151        train_btree_index(
5152            range1_data_source,
5153            old_store.as_ref(),
5154            DEFAULT_BTREE_BATCH_SIZE,
5155            None,
5156            Option::from(1u32),
5157        )
5158        .await
5159        .unwrap();
5160
5161        // Create range 2 index, also intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
5162        let start_val = (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
5163        let end_val = (4 * DEFAULT_BTREE_BATCH_SIZE) as i32;
5164        let values_second_half: Vec<i32> = (start_val..end_val).collect();
5165        let row_ids_second_half: Vec<u64> = (start_val as u64..end_val as u64).collect();
5166        let range2_gen = gen_batch()
5167            .col("value", array::cycle::<Int32Type>(values_second_half))
5168            .col("_rowid", array::cycle::<UInt64Type>(row_ids_second_half))
5169            .into_df_stream(
5170                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
5171                BatchCount::from(3),
5172            );
5173        let range2_data_source = Box::pin(RecordBatchStreamAdapter::new(
5174            range2_gen.schema(),
5175            range2_gen,
5176        ));
5177
5178        train_btree_index(
5179            range2_data_source,
5180            old_store.as_ref(),
5181            DEFAULT_BTREE_BATCH_SIZE,
5182            None,
5183            Option::from(2u32),
5184        )
5185        .await
5186        .unwrap();
5187
5188        // Merge the fragment files
5189        let part_page_files = vec![
5190            part_page_data_file_path(1 << 32),
5191            part_page_data_file_path(2 << 32),
5192        ];
5193
5194        let part_lookup_files = vec![
5195            part_lookup_file_path(1 << 32),
5196            part_lookup_file_path(2 << 32),
5197        ];
5198
5199        super::merge_metadata_files(
5200            old_store.as_ref(),
5201            &part_page_files,
5202            &part_lookup_files,
5203            Option::from(1usize),
5204            noop_progress(),
5205        )
5206        .await
5207        .unwrap();
5208
5209        // create some update data
5210        let start_val = (DEFAULT_BTREE_BATCH_SIZE * 2) as i32;
5211        let end_val = (DEFAULT_BTREE_BATCH_SIZE * 3) as i32;
5212        let row_id_delta = (DEFAULT_BTREE_BATCH_SIZE * 3) as i32;
5213        let values: Vec<i32> = (start_val..end_val).collect();
5214        let row_ids: Vec<u64> =
5215            ((start_val + row_id_delta) as u64..(end_val + row_id_delta) as u64).collect();
5216        let update_data = gen_batch()
5217            .col("value", array::cycle::<Int32Type>(values))
5218            .col("_rowid", array::cycle::<UInt64Type>(row_ids))
5219            .into_df_stream(
5220                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
5221                BatchCount::from(2),
5222            );
5223        let update_data_source = Box::pin(RecordBatchStreamAdapter::new(
5224            update_data.schema(),
5225            update_data,
5226        ));
5227
5228        let ranged_index = BTreeIndex::load(old_store.clone(), None, &LanceCache::no_cache())
5229            .await
5230            .unwrap();
5231
5232        // update the ranged index
5233        ranged_index
5234            .update(update_data_source, new_store.as_ref(), None)
5235            .await
5236            .expect("Error in updating ranged index");
5237
5238        let updated_index = BTreeIndex::load(new_store.clone(), None, &LanceCache::no_cache())
5239            .await
5240            .unwrap();
5241
5242        assert!(
5243            updated_index.ranges_to_files.is_none(),
5244            "Updated ranged-btree-index should fall back to non-ranged"
5245        );
5246
5247        let updated_value = (DEFAULT_BTREE_BATCH_SIZE * 2 + (DEFAULT_BTREE_BATCH_SIZE / 2)) as i32;
5248        let updated_query = SargableQuery::Equals(ScalarValue::Int32(Some(updated_value)));
5249
5250        let query_result = updated_index
5251            .search(&updated_query, &NoOpMetricsCollector)
5252            .await
5253            .unwrap();
5254        match query_result {
5255            SearchResult::Exact(row_id_map) => {
5256                assert!(
5257                    row_id_map.selected(updated_value as u64),
5258                    "Updated index should contain original rowids."
5259                );
5260                assert!(
5261                    row_id_map.selected((updated_value + row_id_delta) as u64),
5262                    "Updated index should contain new rowids"
5263                );
5264            }
5265            _ => {
5266                panic!("Btree search result should always be Exact.");
5267            }
5268        }
5269    }
5270
5271    #[tokio::test]
5272    async fn test_update_with_exact_row_id_filter() {
5273        let old_tmpdir = TempObjDir::default();
5274        let old_store = Arc::new(LanceIndexStore::new(
5275            Arc::new(ObjectStore::local()),
5276            old_tmpdir.clone(),
5277            Arc::new(LanceCache::no_cache()),
5278        ));
5279
5280        let new_tmpdir = TempObjDir::default();
5281        let new_store = Arc::new(LanceIndexStore::new(
5282            Arc::new(ObjectStore::local()),
5283            new_tmpdir.clone(),
5284            Arc::new(LanceCache::no_cache()),
5285        ));
5286
5287        let old_data = gen_batch()
5288            .col("value", array::step::<Int32Type>())
5289            .col("_rowid", array::step::<UInt64Type>())
5290            .into_df_stream(RowCount::from(512), BatchCount::from(2));
5291        let old_data_source = Box::pin(RecordBatchStreamAdapter::new(old_data.schema(), old_data));
5292        train_btree_index(
5293            old_data_source,
5294            old_store.as_ref(),
5295            DEFAULT_BTREE_BATCH_SIZE,
5296            None,
5297            None,
5298        )
5299        .await
5300        .unwrap();
5301
5302        let index = BTreeIndex::load(old_store.clone(), None, &LanceCache::no_cache())
5303            .await
5304            .unwrap();
5305
5306        let new_data = gen_batch()
5307            .col("value", array::step_custom::<Int32Type>(2000, 1))
5308            .col("_rowid", array::step_custom::<UInt64Type>(2000, 1))
5309            .into_df_stream(RowCount::from(100), BatchCount::from(1));
5310        let new_data_source = Box::pin(RecordBatchStreamAdapter::new(new_data.schema(), new_data));
5311
5312        let mut retained_old_rows = RowAddrTreeMap::new();
5313        retained_old_rows.insert_range(0..64);
5314        retained_old_rows.insert_range(300..364);
5315
5316        index
5317            .update(
5318                new_data_source,
5319                new_store.as_ref(),
5320                Some(OldIndexDataFilter::RowIds(retained_old_rows)),
5321            )
5322            .await
5323            .unwrap();
5324
5325        let updated_index = BTreeIndex::load(new_store.clone(), None, &LanceCache::no_cache())
5326            .await
5327            .unwrap();
5328
5329        let present = |value: i32| {
5330            let updated_index = updated_index.clone();
5331            async move {
5332                let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
5333                match updated_index
5334                    .search(&query, &NoOpMetricsCollector)
5335                    .await
5336                    .unwrap()
5337                {
5338                    SearchResult::Exact(row_id_map) => row_id_map.selected(value as u64),
5339                    _ => unreachable!("Btree search result should always be Exact"),
5340                }
5341            }
5342        };
5343
5344        assert!(present(12).await);
5345        assert!(present(320).await);
5346        assert!(!present(120).await);
5347        assert!(!present(420).await);
5348        assert!(present(2005).await);
5349    }
5350
5351    /// Rust equivalent of Python test `test_btree_remap_big_deletions`
5352    ///
5353    /// This test verifies that btree index remapping works correctly when a large
5354    /// portion of the data is deleted. The Python test:
5355    /// 1. Writes 15K rows in 3 fragments (values 0-14999)
5356    /// 2. Creates a btree index (will have multiple pages)
5357    /// 3. Deletes rows where a > 1000 AND a < 10000 (deletes values 1001-9999)
5358    /// 4. Runs compaction (materializes deletions via remap)
5359    /// 5. Verifies the index still works for remaining values
5360    #[tokio::test]
5361    async fn test_btree_remap_big_deletions() {
5362        let tmpdir = TempObjDir::default();
5363        let test_store = Arc::new(LanceIndexStore::new(
5364            Arc::new(ObjectStore::local()),
5365            tmpdir.clone(),
5366            Arc::new(LanceCache::no_cache()),
5367        ));
5368
5369        // Generate 15000 rows with values 0-14999 and row_ids 0-14999
5370        // Using a smaller batch size to ensure we get multiple pages
5371        let batch_size = 4096;
5372        let total_rows = 15000;
5373
5374        let stream = gen_batch()
5375            .col("value", array::step::<Int32Type>())
5376            .col("_rowid", array::step::<UInt64Type>())
5377            .into_df_stream(RowCount::from(total_rows), BatchCount::from(1));
5378
5379        train_btree_index(stream, test_store.as_ref(), batch_size, None, None)
5380            .await
5381            .unwrap();
5382
5383        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
5384            .await
5385            .unwrap();
5386
5387        // Create a mapping that simulates deleting rows where value > 1000 AND value < 10000
5388        // Since values match row_ids in our test data:
5389        // - Rows 0-1000 (values 0-1000) are kept with same row_ids
5390        // - Rows 1001-9999 (values 1001-9999) are deleted (mapped to None)
5391        // - Rows 10000-14999 (values 10000-14999) are remapped to new row_ids 1001-5999
5392        let mut mapping: HashMap<u64, Option<u64>> = HashMap::new();
5393
5394        // Mark deleted rows (values 1001-9999)
5395        for old_id in 1001..10000 {
5396            mapping.insert(old_id, None);
5397        }
5398
5399        // Remap all other rows
5400        for (new_id, old_id) in (100_000..).zip((0..1000).chain(10000..15000)) {
5401            mapping.insert(old_id, Some(new_id));
5402        }
5403
5404        let remap_dir = TempObjDir::default();
5405        let remap_store = Arc::new(LanceIndexStore::new(
5406            Arc::new(ObjectStore::local()),
5407            remap_dir.clone(),
5408            Arc::new(LanceCache::no_cache()),
5409        ));
5410
5411        // Remap the index with our deletion mapping
5412        index
5413            .remap(&RowAddrRemap::direct(mapping), remap_store.as_ref())
5414            .await
5415            .unwrap();
5416
5417        let remapped_index = BTreeIndex::load(remap_store.clone(), None, &LanceCache::no_cache())
5418            .await
5419            .unwrap();
5420
5421        // Verify values that should exist (values 0-1000 and 10000-14999)
5422        // These correspond to: original values 0-1000 at row_ids 0-1000
5423        // and original values 10000-14999 at new row_ids 1001-5999
5424        let should_exist = vec![0, 500, 1000, 10000, 13000, 14000, 14999];
5425        for value in should_exist {
5426            let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
5427            let result = remapped_index
5428                .search(&query, &NoOpMetricsCollector)
5429                .await
5430                .unwrap();
5431            match result {
5432                SearchResult::Exact(row_id_map) => {
5433                    assert!(
5434                        !row_id_map.is_empty(),
5435                        "Value {} should exist in remapped index but was not found",
5436                        value
5437                    );
5438                }
5439                _ => {
5440                    panic!("Btree search result should always be Exact.");
5441                }
5442            }
5443        }
5444
5445        // Verify values that should NOT exist (values 1001-9999 were deleted)
5446        let should_not_exist = vec![1001, 5000, 8000, 9999];
5447        for value in should_not_exist {
5448            let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
5449            let result = remapped_index
5450                .search(&query, &NoOpMetricsCollector)
5451                .await
5452                .unwrap();
5453            match result {
5454                SearchResult::Exact(row_id_map) => {
5455                    assert!(
5456                        row_id_map.is_empty(),
5457                        "Value {} should NOT exist in remapped index but was found",
5458                        value
5459                    );
5460                }
5461                _ => {
5462                    panic!("Btree search result should always be Exact.");
5463                }
5464            }
5465        }
5466    }
5467
5468    /// Regression test: BTree search must track null row IDs for non-IsNull
5469    /// queries, even when no pages match the queried value.
5470    ///
5471    /// Without this, `NOT(x = val)` when `val` is absent from the data would
5472    /// produce an empty null set, causing NULL rows to incorrectly pass.
5473    #[tokio::test]
5474    async fn test_search_tracks_nulls_for_absent_value() {
5475        use arrow_array::{Int32Array, UInt64Array};
5476
5477        let tmpdir = TempObjDir::default();
5478        let test_store = Arc::new(LanceIndexStore::new(
5479            Arc::new(ObjectStore::local()),
5480            tmpdir.clone(),
5481            Arc::new(LanceCache::no_cache()),
5482        ));
5483
5484        // Create data with 80% nulls so that training produces separate
5485        // all-null pages (which are not in the BTree map). Non-null values
5486        // are all in [100, 5099], so value 0 never appears.
5487        let num_rows = 5000u64;
5488        let values: Int32Array = (0..num_rows)
5489            .map(|i| {
5490                if i % 5 != 0 {
5491                    None // 80% null
5492                } else {
5493                    Some(100 + i as i32) // non-null values in [100, 5099]
5494                }
5495            })
5496            .collect();
5497        let row_ids = UInt64Array::from_iter_values(0..num_rows);
5498        let data = arrow_array::RecordBatch::try_from_iter(vec![
5499            ("value", Arc::new(values) as arrow_array::ArrayRef),
5500            ("_rowid", Arc::new(row_ids) as arrow_array::ArrayRef),
5501        ])
5502        .unwrap();
5503
5504        let schema = data.schema();
5505        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
5506            schema,
5507            stream::iter(vec![Ok(data)]),
5508        ));
5509        train_btree_index(stream, test_store.as_ref(), num_rows, None, None)
5510            .await
5511            .unwrap();
5512
5513        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
5514            .await
5515            .unwrap();
5516
5517        // Verify we have all-null pages (the bug depends on this)
5518        assert!(
5519            !index.page_lookup.all_null_pages.is_empty(),
5520            "Test setup requires all-null pages; got null_pages={}, all_null_pages={}",
5521            index.page_lookup.null_pages.len(),
5522            index.page_lookup.all_null_pages.len(),
5523        );
5524
5525        let metrics = NoOpMetricsCollector;
5526
5527        // Search for Equals(0) — value 0 doesn't exist in any page
5528        let result = index
5529            .search(
5530                &SargableQuery::Equals(ScalarValue::Int32(Some(0))),
5531                &metrics,
5532            )
5533            .await
5534            .unwrap();
5535
5536        match result {
5537            SearchResult::Exact(set) => {
5538                // No rows should be TRUE (value 0 doesn't exist)
5539                assert!(set.true_rows().is_empty(), "No rows should match Equals(0)");
5540                // NULL rows MUST be tracked as null
5541                assert!(
5542                    !set.null_rows().is_empty(),
5543                    "Null rows must be tracked even when no pages match the value"
5544                );
5545            }
5546            _ => panic!("BTree search should return Exact"),
5547        }
5548
5549        // Also verify Range query tracks nulls when no values match
5550        let result = index
5551            .search(
5552                &SargableQuery::Range(
5553                    std::ops::Bound::Unbounded,
5554                    std::ops::Bound::Excluded(ScalarValue::Int32(Some(50))),
5555                ),
5556                &metrics,
5557            )
5558            .await
5559            .unwrap();
5560
5561        match result {
5562            SearchResult::Exact(set) => {
5563                assert!(set.true_rows().is_empty(), "No rows should be < 50");
5564                assert!(
5565                    !set.null_rows().is_empty(),
5566                    "Null rows must be tracked for range queries too"
5567                );
5568            }
5569            _ => panic!("BTree search should return Exact"),
5570        }
5571    }
5572
5573    fn sample_lookup_batch() -> RecordBatch {
5574        record_batch!(
5575            ("min", Int32, [Some(0), Some(10), Some(20)]),
5576            ("max", Int32, [Some(9), Some(19), Some(29)]),
5577            ("null_count", UInt32, [0, 2, 0]),
5578            ("page_idx", UInt32, [0, 1, 2])
5579        )
5580        .unwrap()
5581    }
5582
5583    fn osv(v: i32) -> OrderableScalarValue {
5584        OrderableScalarValue(ScalarValue::Int32(Some(v)))
5585    }
5586
5587    /// The rewritten [`BTreeLookup`] searches the lookup batch directly, so this
5588    /// exercises the binary-search bounds, duplicate `min` values, a partial-null
5589    /// (null `min`) straddling page, and the `Matches::Some`/`All` classification.
5590    #[test]
5591    fn test_btree_lookup_pages_between() {
5592        // Pages sorted by `min`, NULLs first. Page 0 straddles the NULL/non-NULL
5593        // boundary; pages 2 and 3 share a `min` of 20.
5594        let batch = record_batch!(
5595            ("min", Int32, [None, Some(10), Some(20), Some(20), Some(40)]),
5596            (
5597                "max",
5598                Int32,
5599                [Some(5), Some(20), Some(20), Some(30), Some(50)]
5600            ),
5601            ("null_count", UInt32, [2, 0, 0, 0, 0]),
5602            ("page_idx", UInt32, [0, 1, 2, 3, 4])
5603        )
5604        .unwrap();
5605        let lookup = BTreeLookup::try_new(batch).unwrap();
5606        assert_eq!(lookup.null_pages, vec![0]);
5607        assert!(lookup.all_null_pages.is_empty());
5608        assert_eq!(lookup.search_start, 0);
5609
5610        let between = |lo: i32, hi: i32| {
5611            let mut m = lookup
5612                .pages_between((
5613                    std::ops::Bound::Included(&osv(lo)),
5614                    std::ops::Bound::Included(&osv(hi)),
5615                ))
5616                .unwrap();
5617            m.sort_by_key(|m| m.page_id());
5618            m
5619        };
5620
5621        // Equality only ever yields partial (Some) matches.
5622        assert_eq!(lookup.pages_eq(&osv(15)).unwrap(), vec![Matches::Some(1)]);
5623        assert_eq!(
5624            lookup.pages_eq(&osv(20)).unwrap(),
5625            vec![Matches::Some(1), Matches::Some(2), Matches::Some(3)]
5626        );
5627        assert!(lookup.pages_eq(&osv(35)).unwrap().is_empty());
5628
5629        // [20, 25]: page 2 ([20, 20]) sits entirely inside -> All; pages 1 and 3
5630        // only partially overlap -> Some. The null-min page 0 (max 5) is excluded.
5631        assert_eq!(
5632            between(20, 25),
5633            vec![Matches::Some(1), Matches::All(2), Matches::Some(3)]
5634        );
5635
5636        // A query below all non-null data still reaches the straddling page 0,
5637        // which is only ever a partial match because its `min` is NULL.
5638        assert_eq!(between(0, 5), vec![Matches::Some(0)]);
5639
5640        // Unbounded above: page 4 ([40, 50]) is fully covered from 40 onward.
5641        assert_eq!(
5642            lookup
5643                .pages_between((
5644                    std::ops::Bound::Included(&osv(40)),
5645                    std::ops::Bound::Unbounded
5646                ))
5647                .unwrap(),
5648            vec![Matches::All(4)]
5649        );
5650
5651        // Empty / inverted ranges select nothing.
5652        assert!(between(31, 39).is_empty());
5653        assert!(
5654            lookup
5655                .pages_between((
5656                    std::ops::Bound::Included(&osv(25)),
5657                    std::ops::Bound::Included(&osv(15))
5658                ))
5659                .unwrap()
5660                .is_empty()
5661        );
5662    }
5663
5664    /// Exercises the native byte comparator path (`accessor_cmp`) for
5665    /// variable-length `Binary` and fixed-width `FixedSizeBinary` (e.g. UUID)
5666    /// columns, including the null-min straddle page and duplicate `min`s.
5667    #[test]
5668    fn test_btree_lookup_pages_eq_bytes() {
5669        use arrow_array::{
5670            ArrayRef, BinaryArray, FixedSizeBinaryArray, LargeBinaryArray, LargeStringArray,
5671            UInt32Array,
5672        };
5673        use arrow_schema::{DataType, Field, Schema};
5674
5675        // 2-byte big-endian keys, so lexicographic byte order matches numeric
5676        // order. Same layout as the int test: page 0 is a null-min straddle,
5677        // pages 2 and 3 share `min` 20, and 35 falls in a gap.
5678        fn be(v: u16) -> [u8; 2] {
5679            v.to_be_bytes()
5680        }
5681        let mins = [None, Some(10u16), Some(20), Some(20), Some(40)];
5682        let maxs = [Some(5u16), Some(20), Some(20), Some(30), Some(50)];
5683        let null_count = UInt32Array::from(vec![2u32, 0, 0, 0, 0]);
5684        let page_idx = UInt32Array::from(vec![0u32, 1, 2, 3, 4]);
5685
5686        let assert_byte_lookup =
5687            |min_arr: ArrayRef, max_arr: ArrayRef, sv: &dyn Fn(u16) -> ScalarValue| {
5688                let batch = RecordBatch::try_new(
5689                    Arc::new(Schema::new(vec![
5690                        Field::new("min", min_arr.data_type().clone(), true),
5691                        Field::new("max", max_arr.data_type().clone(), true),
5692                        Field::new("null_count", DataType::UInt32, false),
5693                        Field::new("page_idx", DataType::UInt32, false),
5694                    ])),
5695                    vec![
5696                        min_arr,
5697                        max_arr,
5698                        Arc::new(null_count.clone()),
5699                        Arc::new(page_idx.clone()),
5700                    ],
5701                )
5702                .unwrap();
5703                let lookup = BTreeLookup::try_new(batch).unwrap();
5704
5705                let eq = |v: u16| {
5706                    let mut p: Vec<u32> = lookup
5707                        .pages_eq(&OrderableScalarValue(sv(v)))
5708                        .unwrap()
5709                        .into_iter()
5710                        .map(|m| m.page_id())
5711                        .collect();
5712                    p.sort_unstable();
5713                    p
5714                };
5715                assert_eq!(eq(15), vec![1]); // only page 1 ([10, 20])
5716                assert_eq!(eq(20), vec![1, 2, 3]); // shared min of 2 & 3, max of 1
5717                assert!(eq(35).is_empty()); // gap between pages 3 and 4
5718                assert_eq!(eq(5), vec![0]); // reaches the null-min straddle via its max
5719
5720                // IN merges and dedups across values.
5721                let mut in_pages: Vec<u32> = lookup
5722                    .pages_in([5u16, 15].into_iter().map(|v| OrderableScalarValue(sv(v))))
5723                    .unwrap()
5724                    .into_iter()
5725                    .map(|m| m.page_id())
5726                    .collect();
5727                in_pages.sort_unstable();
5728                assert_eq!(in_pages, vec![0, 1]);
5729            };
5730
5731        let fsb = |arr: &[Option<u16>]| -> ArrayRef {
5732            Arc::new(
5733                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
5734                    arr.iter().copied().map(|o| o.map(be)),
5735                    2,
5736                )
5737                .unwrap(),
5738            )
5739        };
5740        assert_byte_lookup(fsb(&mins), fsb(&maxs), &|v| {
5741            ScalarValue::FixedSizeBinary(2, Some(be(v).to_vec()))
5742        });
5743
5744        let bin = |arr: &[Option<u16>]| -> ArrayRef {
5745            Arc::new(BinaryArray::from_iter(
5746                arr.iter().copied().map(|o| o.map(|v| be(v).to_vec())),
5747            ))
5748        };
5749        assert_byte_lookup(bin(&mins), bin(&maxs), &|v| {
5750            ScalarValue::Binary(Some(be(v).to_vec()))
5751        });
5752
5753        let lbin = |arr: &[Option<u16>]| -> ArrayRef {
5754            Arc::new(LargeBinaryArray::from_iter(
5755                arr.iter().copied().map(|o| o.map(|v| be(v).to_vec())),
5756            ))
5757        };
5758        assert_byte_lookup(lbin(&mins), lbin(&maxs), &|v| {
5759            ScalarValue::LargeBinary(Some(be(v).to_vec()))
5760        });
5761
5762        // `LargeUtf8` over zero-padded decimal strings, whose lexicographic order
5763        // matches the numeric order of the keys.
5764        let lstr = |arr: &[Option<u16>]| -> ArrayRef {
5765            Arc::new(LargeStringArray::from_iter(
5766                arr.iter().copied().map(|o| o.map(|v| format!("{v:02}"))),
5767            ))
5768        };
5769        assert_byte_lookup(lstr(&mins), lstr(&maxs), &|v| {
5770            ScalarValue::LargeUtf8(Some(format!("{v:02}")))
5771        });
5772    }
5773
5774    /// Exercises the physical-type reinterpret path: temporal columns (`Date32`
5775    /// over `i32`, `Timestamp` over `i64`) are compared through the integer native
5776    /// path without a dedicated per-type branch.
5777    #[test]
5778    fn test_btree_lookup_pages_eq_temporal() {
5779        use arrow_array::{ArrayRef, Date32Array, TimestampMicrosecondArray, UInt32Array};
5780        use arrow_schema::{DataType, Field, Schema};
5781
5782        let null_count = UInt32Array::from(vec![2u32, 0, 0, 0, 0]);
5783        let page_idx = UInt32Array::from(vec![0u32, 1, 2, 3, 4]);
5784
5785        let assert_lookup =
5786            |min_arr: ArrayRef, max_arr: ArrayRef, sv: &dyn Fn(i64) -> ScalarValue| {
5787                let batch = RecordBatch::try_new(
5788                    Arc::new(Schema::new(vec![
5789                        Field::new("min", min_arr.data_type().clone(), true),
5790                        Field::new("max", max_arr.data_type().clone(), true),
5791                        Field::new("null_count", DataType::UInt32, false),
5792                        Field::new("page_idx", DataType::UInt32, false),
5793                    ])),
5794                    vec![
5795                        min_arr,
5796                        max_arr,
5797                        Arc::new(null_count.clone()),
5798                        Arc::new(page_idx.clone()),
5799                    ],
5800                )
5801                .unwrap();
5802                let lookup = BTreeLookup::try_new(batch).unwrap();
5803                let eq = |v: i64| {
5804                    let mut p: Vec<u32> = lookup
5805                        .pages_eq(&OrderableScalarValue(sv(v)))
5806                        .unwrap()
5807                        .into_iter()
5808                        .map(|m| m.page_id())
5809                        .collect();
5810                    p.sort_unstable();
5811                    p
5812                };
5813                assert_eq!(eq(15), vec![1]); // only page 1 ([10, 20])
5814                assert_eq!(eq(20), vec![1, 2, 3]); // shared min of 2 & 3, max of 1
5815                assert!(eq(35).is_empty()); // gap between pages 3 and 4
5816                assert_eq!(eq(5), vec![0]); // reaches the null-min straddle via its max
5817            };
5818
5819        // Timestamp (i64-backed) → Int64 native path.
5820        assert_lookup(
5821            Arc::new(TimestampMicrosecondArray::from(vec![
5822                None,
5823                Some(10),
5824                Some(20),
5825                Some(20),
5826                Some(40),
5827            ])),
5828            Arc::new(TimestampMicrosecondArray::from(vec![
5829                Some(5),
5830                Some(20),
5831                Some(20),
5832                Some(30),
5833                Some(50),
5834            ])),
5835            &|v| ScalarValue::TimestampMicrosecond(Some(v), None),
5836        );
5837
5838        // Date32 (i32-backed) → Int32 native path.
5839        assert_lookup(
5840            Arc::new(Date32Array::from(vec![
5841                None,
5842                Some(10),
5843                Some(20),
5844                Some(20),
5845                Some(40),
5846            ])),
5847            Arc::new(Date32Array::from(vec![
5848                Some(5),
5849                Some(20),
5850                Some(20),
5851                Some(30),
5852                Some(50),
5853            ])),
5854            &|v| ScalarValue::Date32(Some(v as i32)),
5855        );
5856    }
5857
5858    /// Exercises the remaining physical-type dispatch arms that the temporal and
5859    /// byte tests don't reach: every integer width and signedness, `Float16`, and
5860    /// the 128-/256-bit decimal paths. All share the temporal test's numeric layout
5861    /// (mins `[_, 10, 20, 20, 40]`, maxs `[5, 20, 20, 30, 50]`) so the assertions are
5862    /// identical; only the array/scalar type varies.
5863    #[test]
5864    fn test_btree_lookup_pages_eq_numeric_widths() {
5865        use arrow::datatypes::i256;
5866        use arrow_array::{
5867            ArrayRef, Decimal128Array, Decimal256Array, Float16Array, Int8Array, Int16Array,
5868            UInt8Array, UInt16Array, UInt32Array, UInt64Array,
5869        };
5870        use arrow_schema::{DataType, Field, Schema};
5871        use half::f16;
5872
5873        let null_count = UInt32Array::from(vec![2u32, 0, 0, 0, 0]);
5874        let page_idx = UInt32Array::from(vec![0u32, 1, 2, 3, 4]);
5875        let assert_lookup =
5876            |min_arr: ArrayRef, max_arr: ArrayRef, sv: &dyn Fn(i64) -> ScalarValue| {
5877                let batch = RecordBatch::try_new(
5878                    Arc::new(Schema::new(vec![
5879                        Field::new("min", min_arr.data_type().clone(), true),
5880                        Field::new("max", max_arr.data_type().clone(), true),
5881                        Field::new("null_count", DataType::UInt32, false),
5882                        Field::new("page_idx", DataType::UInt32, false),
5883                    ])),
5884                    vec![
5885                        min_arr,
5886                        max_arr,
5887                        Arc::new(null_count.clone()),
5888                        Arc::new(page_idx.clone()),
5889                    ],
5890                )
5891                .unwrap();
5892                let lookup = BTreeLookup::try_new(batch).unwrap();
5893                let eq = |v: i64| {
5894                    let mut p: Vec<u32> = lookup
5895                        .pages_eq(&OrderableScalarValue(sv(v)))
5896                        .unwrap()
5897                        .into_iter()
5898                        .map(|m| m.page_id())
5899                        .collect();
5900                    p.sort_unstable();
5901                    p
5902                };
5903                assert_eq!(eq(15), vec![1]); // only page 1 ([10, 20])
5904                assert_eq!(eq(20), vec![1, 2, 3]); // shared min of 2 & 3, max of 1
5905                assert!(eq(35).is_empty()); // gap between pages 3 and 4
5906                assert_eq!(eq(5), vec![0]); // reaches the null-min straddle via its max
5907            };
5908
5909        assert_lookup(
5910            Arc::new(Int8Array::from(vec![
5911                None,
5912                Some(10),
5913                Some(20),
5914                Some(20),
5915                Some(40),
5916            ])),
5917            Arc::new(Int8Array::from(vec![
5918                Some(5),
5919                Some(20),
5920                Some(20),
5921                Some(30),
5922                Some(50),
5923            ])),
5924            &|v| ScalarValue::Int8(Some(v as i8)),
5925        );
5926        assert_lookup(
5927            Arc::new(Int16Array::from(vec![
5928                None,
5929                Some(10),
5930                Some(20),
5931                Some(20),
5932                Some(40),
5933            ])),
5934            Arc::new(Int16Array::from(vec![
5935                Some(5),
5936                Some(20),
5937                Some(20),
5938                Some(30),
5939                Some(50),
5940            ])),
5941            &|v| ScalarValue::Int16(Some(v as i16)),
5942        );
5943        assert_lookup(
5944            Arc::new(UInt8Array::from(vec![
5945                None,
5946                Some(10),
5947                Some(20),
5948                Some(20),
5949                Some(40),
5950            ])),
5951            Arc::new(UInt8Array::from(vec![
5952                Some(5),
5953                Some(20),
5954                Some(20),
5955                Some(30),
5956                Some(50),
5957            ])),
5958            &|v| ScalarValue::UInt8(Some(v as u8)),
5959        );
5960        assert_lookup(
5961            Arc::new(UInt16Array::from(vec![
5962                None,
5963                Some(10),
5964                Some(20),
5965                Some(20),
5966                Some(40),
5967            ])),
5968            Arc::new(UInt16Array::from(vec![
5969                Some(5),
5970                Some(20),
5971                Some(20),
5972                Some(30),
5973                Some(50),
5974            ])),
5975            &|v| ScalarValue::UInt16(Some(v as u16)),
5976        );
5977        assert_lookup(
5978            Arc::new(UInt32Array::from(vec![
5979                None,
5980                Some(10),
5981                Some(20),
5982                Some(20),
5983                Some(40),
5984            ])),
5985            Arc::new(UInt32Array::from(vec![
5986                Some(5),
5987                Some(20),
5988                Some(20),
5989                Some(30),
5990                Some(50),
5991            ])),
5992            &|v| ScalarValue::UInt32(Some(v as u32)),
5993        );
5994        assert_lookup(
5995            Arc::new(UInt64Array::from(vec![
5996                None,
5997                Some(10),
5998                Some(20),
5999                Some(20),
6000                Some(40),
6001            ])),
6002            Arc::new(UInt64Array::from(vec![
6003                Some(5),
6004                Some(20),
6005                Some(20),
6006                Some(30),
6007                Some(50),
6008            ])),
6009            &|v| ScalarValue::UInt64(Some(v as u64)),
6010        );
6011
6012        let f = |v: f64| f16::from_f64(v);
6013        assert_lookup(
6014            Arc::new(Float16Array::from(vec![
6015                None,
6016                Some(f(10.0)),
6017                Some(f(20.0)),
6018                Some(f(20.0)),
6019                Some(f(40.0)),
6020            ])),
6021            Arc::new(Float16Array::from(vec![
6022                Some(f(5.0)),
6023                Some(f(20.0)),
6024                Some(f(20.0)),
6025                Some(f(30.0)),
6026                Some(f(50.0)),
6027            ])),
6028            &|v| ScalarValue::Float16(Some(f(v as f64))),
6029        );
6030
6031        // Decimal128 (i128 native path). Comparison is on the raw integer, so a
6032        // scale of 0 lets the values double as plain integers.
6033        let dec128 = |vals: Vec<Option<i128>>| -> ArrayRef {
6034            Arc::new(
6035                Decimal128Array::from(vals)
6036                    .with_precision_and_scale(18, 0)
6037                    .unwrap(),
6038            )
6039        };
6040        assert_lookup(
6041            dec128(vec![None, Some(10), Some(20), Some(20), Some(40)]),
6042            dec128(vec![Some(5), Some(20), Some(20), Some(30), Some(50)]),
6043            &|v| ScalarValue::Decimal128(Some(v as i128), 18, 0),
6044        );
6045
6046        // Decimal256 (i256 native path).
6047        let dec256 = |vals: Vec<Option<i128>>| -> ArrayRef {
6048            Arc::new(
6049                Decimal256Array::from(
6050                    vals.into_iter()
6051                        .map(|o| o.map(i256::from_i128))
6052                        .collect::<Vec<_>>(),
6053                )
6054                .with_precision_and_scale(40, 0)
6055                .unwrap(),
6056            )
6057        };
6058        assert_lookup(
6059            dec256(vec![None, Some(10), Some(20), Some(20), Some(40)]),
6060            dec256(vec![Some(5), Some(20), Some(20), Some(30), Some(50)]),
6061            &|v| ScalarValue::Decimal256(Some(i256::from_i128(v as i128)), 40, 0),
6062        );
6063    }
6064
6065    /// Exercises the NULL paths of the lookup directly: `pages_eq(NULL)` and
6066    /// `pages_in` with a NULL in the value list (and a NULL-only list), including
6067    /// the partial-null (`Some`) vs entirely-null (`All`) page classification.
6068    #[test]
6069    fn test_btree_lookup_pages_null() {
6070        // Page 0 is entirely null (null max -> All); page 1 is a partial-null
6071        // straddle (max 5, null_count > 0 -> Some); page 2 also carries a null.
6072        let batch = record_batch!(
6073            ("min", Int32, [None, None, Some(10), Some(20), Some(40)]),
6074            ("max", Int32, [None, Some(5), Some(20), Some(30), Some(50)]),
6075            ("null_count", UInt32, [3, 2, 1, 0, 0]),
6076            ("page_idx", UInt32, [0, 1, 2, 3, 4])
6077        )
6078        .unwrap();
6079        let lookup = BTreeLookup::try_new(batch).unwrap();
6080        assert_eq!(lookup.all_null_pages, vec![0]);
6081        assert_eq!(lookup.null_pages, vec![1, 2]);
6082
6083        // pages_eq(NULL) short-circuits to the null pages: partial-null pages are
6084        // `Some`, the entirely-null page is `All`.
6085        assert_eq!(
6086            lookup
6087                .pages_eq(&OrderableScalarValue(ScalarValue::Int32(None)))
6088                .unwrap(),
6089            vec![Matches::Some(1), Matches::Some(2), Matches::All(0)]
6090        );
6091
6092        let in_ids = |vals: Vec<Option<i32>>| {
6093            let mut p: Vec<u32> = lookup
6094                .pages_in(
6095                    vals.into_iter()
6096                        .map(|v| OrderableScalarValue(ScalarValue::Int32(v))),
6097                )
6098                .unwrap()
6099                .into_iter()
6100                .map(|m| m.page_id())
6101                .collect();
6102            p.sort_unstable();
6103            p
6104        };
6105        // Baseline: a non-null value only -> just its value page.
6106        assert_eq!(in_ids(vec![Some(45)]), vec![4]);
6107        // A NULL in the list unions in every null page (0, 1, 2).
6108        assert_eq!(in_ids(vec![Some(45), None]), vec![0, 1, 2, 4]);
6109        // A NULL-only list (empty non-null set) returns exactly the null pages.
6110        assert_eq!(in_ids(vec![None]), vec![0, 1, 2]);
6111    }
6112
6113    /// A 0-row page_lookup batch (an index over an empty dataset) must yield no
6114    /// candidates for any query rather than panicking on the binary-search bounds.
6115    #[test]
6116    fn test_btree_lookup_empty_batch() {
6117        use arrow_schema::{DataType, Field, Schema};
6118
6119        let schema = Arc::new(Schema::new(vec![
6120            Field::new("min", DataType::Int32, true),
6121            Field::new("max", DataType::Int32, true),
6122            Field::new("null_count", DataType::UInt32, false),
6123            Field::new("page_idx", DataType::UInt32, false),
6124        ]));
6125        let lookup = BTreeLookup::try_new(RecordBatch::new_empty(schema)).unwrap();
6126        assert_eq!(lookup.search_start, 0);
6127        assert!(lookup.null_pages.is_empty());
6128        assert!(lookup.all_null_pages.is_empty());
6129
6130        assert!(lookup.pages_eq(&osv(5)).unwrap().is_empty());
6131        assert!(lookup.pages_in([osv(5)]).unwrap().is_empty());
6132        assert!(
6133            lookup
6134                .pages_between((
6135                    std::ops::Bound::Included(&osv(0)),
6136                    std::ops::Bound::Included(&osv(100)),
6137                ))
6138                .unwrap()
6139                .is_empty()
6140        );
6141        assert!(lookup.pages_null().is_empty());
6142    }
6143
6144    /// A straddle page (null `min`, non-null `max`) can sort ahead of an entirely-
6145    /// null page within the leading NULL-`min` group. When it does, `search_start`
6146    /// points at the straddle and the all-null page falls inside the forward-scan
6147    /// window, so both the equality and range scans must skip it (it matches only
6148    /// IS NULL).
6149    #[test]
6150    fn test_btree_lookup_skips_all_null_page_in_scan_window() {
6151        // Page 0: straddle (null min, max 5). Page 1: entirely null (null min/max).
6152        let batch = record_batch!(
6153            ("min", Int32, [None, None, Some(10), Some(20), Some(40)]),
6154            ("max", Int32, [Some(5), None, Some(20), Some(30), Some(50)]),
6155            ("null_count", UInt32, [2, 3, 0, 0, 0]),
6156            ("page_idx", UInt32, [0, 1, 2, 3, 4])
6157        )
6158        .unwrap();
6159        let lookup = BTreeLookup::try_new(batch).unwrap();
6160        assert_eq!(lookup.search_start, 0); // straddle page 0 has a non-null max
6161        assert_eq!(lookup.all_null_pages, vec![1]);
6162        assert_eq!(lookup.null_pages, vec![0]);
6163
6164        // Equality for 5 peeks left across the all-null page 1 (index 1, inside the
6165        // scan window) and must skip it, reaching only the straddle page 0.
6166        assert_eq!(
6167            lookup
6168                .pages_eq(&osv(5))
6169                .unwrap()
6170                .into_iter()
6171                .map(|m| m.page_id())
6172                .collect::<Vec<_>>(),
6173            vec![0]
6174        );
6175
6176        // The same all-null page sits inside the range scan window and is skipped:
6177        // page 0 (straddle) is a partial match, pages 2-4 are fully covered.
6178        let mut between = lookup
6179            .pages_between((
6180                std::ops::Bound::Included(&osv(0)),
6181                std::ops::Bound::Included(&osv(100)),
6182            ))
6183            .unwrap();
6184        between.sort_by_key(|m| m.page_id());
6185        assert_eq!(
6186            between,
6187            vec![
6188                Matches::Some(0),
6189                Matches::All(2),
6190                Matches::All(3),
6191                Matches::All(4),
6192            ]
6193        );
6194    }
6195
6196    fn assert_state_roundtrips(state: &BTreeIndexState) {
6197        let restored = deserialize_state(serialize_state(state)).unwrap();
6198        assert_eq!(restored.lookup_batch, state.lookup_batch);
6199        assert_eq!(restored.batch_size, state.batch_size);
6200        assert_eq!(restored.ranges_to_files, state.ranges_to_files);
6201    }
6202
6203    #[test]
6204    fn test_btree_page_key_codec() {
6205        // FlatIndex pages can be serialized by a persistent cache backend.
6206        assert!(BTreePageKey::codec().is_some());
6207    }
6208
6209    #[test]
6210    fn test_btree_index_state_roundtrip() {
6211        // Not range-partitioned.
6212        assert_state_roundtrips(&BTreeIndexState {
6213            lookup_batch: sample_lookup_batch(),
6214            batch_size: DEFAULT_BTREE_BATCH_SIZE,
6215            ranges_to_files: None,
6216        });
6217
6218        // Range-partitioned across multiple files.
6219        let ranges: RangeInclusiveMap<u32, (String, u32)> = [
6220            (0..=99, ("part_0_page_file.lance".to_string(), 0)),
6221            (100..=199, ("part_1_page_file.lance".to_string(), 100)),
6222        ]
6223        .into_iter()
6224        .collect();
6225        assert_state_roundtrips(&BTreeIndexState {
6226            lookup_batch: sample_lookup_batch(),
6227            batch_size: 8192,
6228            ranges_to_files: Some(Arc::new(ranges)),
6229        });
6230
6231        // Empty index.
6232        assert_state_roundtrips(&BTreeIndexState {
6233            lookup_batch: RecordBatch::new_empty(sample_lookup_batch().schema()),
6234            batch_size: DEFAULT_BTREE_BATCH_SIZE,
6235            ranges_to_files: None,
6236        });
6237    }
6238
6239    #[tokio::test]
6240    async fn test_btree_index_state_reconstruct_and_plugin_cache() {
6241        let tmpdir = TempObjDir::default();
6242        let test_store = Arc::new(LanceIndexStore::new(
6243            Arc::new(ObjectStore::local()),
6244            tmpdir.clone(),
6245            Arc::new(LanceCache::no_cache()),
6246        ));
6247
6248        let stream = gen_batch()
6249            .col("value", array::step::<Int32Type>())
6250            .col("_rowid", array::step::<UInt64Type>())
6251            .into_df_stream(RowCount::from(1000), BatchCount::from(5));
6252        train_btree_index(stream, test_store.as_ref(), 1000, None, None)
6253            .await
6254            .unwrap();
6255
6256        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
6257            .await
6258            .unwrap();
6259
6260        // Round-trip the state through the codec and reconstruct an index from it.
6261        let state = BTreeIndexState {
6262            lookup_batch: index.page_lookup.batch.clone(),
6263            batch_size: index.batch_size,
6264            ranges_to_files: index.ranges_to_files.clone(),
6265        };
6266        let restored = deserialize_state(serialize_state(&state)).unwrap();
6267        let reconstructed = restored
6268            .reconstruct(test_store.clone(), &LanceCache::no_cache(), None)
6269            .unwrap();
6270        assert_eq!(
6271            reconstructed
6272                .as_any()
6273                .downcast_ref::<BTreeIndex>()
6274                .unwrap()
6275                .page_lookup,
6276            index.page_lookup
6277        );
6278
6279        // The plugin's put/get hooks round-trip through a real cache + the codec.
6280        let cache = LanceCache::with_capacity(64 * 1024 * 1024);
6281        let plugin = BTreeIndexPlugin;
6282        plugin.put_in_cache(&cache, index.clone()).await.unwrap();
6283        let from_cache = plugin
6284            .get_from_cache(test_store.clone(), None, &cache)
6285            .await
6286            .unwrap()
6287            .expect("index should be served from the cache");
6288
6289        // Searches against the cached index match the original.
6290        let query = SargableQuery::Range(
6291            std::ops::Bound::Included(ScalarValue::Int32(Some(100))),
6292            std::ops::Bound::Excluded(ScalarValue::Int32(Some(200))),
6293        );
6294        let expected = index.search(&query, &NoOpMetricsCollector).await.unwrap();
6295        let actual = from_cache
6296            .search(&query, &NoOpMetricsCollector)
6297            .await
6298            .unwrap();
6299        assert_eq!(expected, actual);
6300    }
6301
6302    /// The lookup batch must decode zero-copy through the full envelope even
6303    /// though the proto header pushes the IPC section to a non-aligned offset.
6304    #[test]
6305    fn test_btree_index_state_lookup_is_zero_copy() {
6306        use lance_core::cache::CacheCodec;
6307        const ALIGN: usize = 64;
6308
6309        let ranges: RangeInclusiveMap<u32, (String, u32)> =
6310            [(0..=99, ("part_0_page_file.lance".to_string(), 0))]
6311                .into_iter()
6312                .collect();
6313        let state = BTreeIndexState {
6314            lookup_batch: sample_lookup_batch(),
6315            batch_size: 8192,
6316            ranges_to_files: Some(Arc::new(ranges)),
6317        };
6318
6319        let codec = CacheCodec::from_impl::<BTreeIndexState>();
6320        let any: Arc<dyn std::any::Any + Send + Sync> = Arc::new(state);
6321        let mut buf = Vec::new();
6322        codec.serialize(&any, &mut buf).unwrap();
6323
6324        let mut v = vec![0u8; buf.len() + ALIGN];
6325        let pad = (ALIGN - (v.as_ptr() as usize % ALIGN)) % ALIGN;
6326        v[pad..pad + buf.len()].copy_from_slice(&buf);
6327        let data = bytes::Bytes::from(v).slice(pad..pad + buf.len());
6328
6329        let restored = codec.deserialize(&data).hit().unwrap();
6330        let restored = restored.downcast::<BTreeIndexState>().unwrap();
6331
6332        let base = data.as_ptr() as usize;
6333        let end = base + data.len();
6334        for col in restored.lookup_batch.columns() {
6335            for buffer in col.to_data().buffers() {
6336                let ptr = buffer.as_ptr() as usize;
6337                assert!(
6338                    ptr >= base && ptr < end,
6339                    "lookup batch buffer was realigned out of the input — misaligned IPC section",
6340                );
6341            }
6342        }
6343    }
6344
6345    #[test]
6346    fn test_btree_index_state_rejects_truncated_header() {
6347        // A header length prefix that overruns the buffer must error rather
6348        // than panic or silently misread it.
6349        let mut buf = Vec::new();
6350        buf.extend_from_slice(&100u32.to_le_bytes()); // claims a 100-byte header
6351        buf.extend_from_slice(&[0u8; 4]); // but only 4 bytes follow
6352        assert!(deserialize_state(buf).is_err());
6353    }
6354
6355    #[tokio::test]
6356    async fn test_btree_index_state_reconstruct_applies_frag_reuse_index() {
6357        use crate::frag_reuse::{FragReuseIndex, FragReuseIndexDetails, FragReuseIndexHandle};
6358        use std::collections::HashMap;
6359        use uuid::Uuid;
6360
6361        let tmpdir = TempObjDir::default();
6362        let test_store = Arc::new(LanceIndexStore::new(
6363            Arc::new(ObjectStore::local()),
6364            tmpdir.clone(),
6365            Arc::new(LanceCache::no_cache()),
6366        ));
6367
6368        // value == _rowid for all rows in [0, 1000).
6369        let stream = gen_batch()
6370            .col("value", array::step::<Int32Type>())
6371            .col("_rowid", array::step::<UInt64Type>())
6372            .into_df_stream(RowCount::from(1000), BatchCount::from(1));
6373        train_btree_index(stream, test_store.as_ref(), 1000, None, None)
6374            .await
6375            .unwrap();
6376
6377        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
6378            .await
6379            .unwrap();
6380        let state = BTreeIndexState {
6381            lookup_batch: index.page_lookup.batch.clone(),
6382            batch_size: index.batch_size,
6383            ranges_to_files: index.ranges_to_files.clone(),
6384        };
6385
6386        // Remap row 0 -> row 5000 (outside the original [0, 1000) range so no collision).
6387        // Querying for value == 0 should now return row 5000, confirming reconstruct threaded
6388        // the FragReuseIndex through to the rebuilt BTreeIndex.
6389        let frag_reuse_index: Arc<dyn crate::scalar::RowIdRemapper> =
6390            Arc::new(FragReuseIndexHandle(Arc::new(FragReuseIndex::new(
6391                Uuid::new_v4(),
6392                vec![HashMap::from([(0u64, Some(5000u64))])],
6393                FragReuseIndexDetails { versions: vec![] },
6394            ))));
6395        let reconstructed = state
6396            .reconstruct(
6397                test_store.clone(),
6398                &LanceCache::no_cache(),
6399                Some(frag_reuse_index),
6400            )
6401            .unwrap();
6402
6403        let result = reconstructed
6404            .search(
6405                &SargableQuery::Equals(ScalarValue::Int32(Some(0))),
6406                &NoOpMetricsCollector,
6407            )
6408            .await
6409            .unwrap();
6410        let row_ids: Vec<u64> = match &result {
6411            SearchResult::Exact(set) => set
6412                .true_rows()
6413                .row_addrs()
6414                .unwrap()
6415                .map(u64::from)
6416                .collect(),
6417            other => panic!("expected Exact, got {other:?}"),
6418        };
6419        assert_eq!(
6420            row_ids,
6421            vec![5000],
6422            "frag_reuse_index remap was not applied"
6423        );
6424    }
6425
6426    #[tokio::test]
6427    async fn test_btree_index_state_range_partitioned_plugin_cache_roundtrip() {
6428        // Build a range-partitioned BTree (two range partitions merged into one index) and
6429        // round-trip it through the plugin's cache hooks. This exercises the
6430        // `ranges_to_files = Some` path end-to-end through serialize/deserialize/reconstruct.
6431        let tmpdir = TempObjDir::default();
6432        let store = Arc::new(LanceIndexStore::new(
6433            Arc::new(ObjectStore::local()),
6434            tmpdir.clone(),
6435            Arc::new(LanceCache::no_cache()),
6436        ));
6437
6438        let half = DEFAULT_BTREE_BATCH_SIZE;
6439        let total = (2 * half) as i32;
6440
6441        // Partition 0: values/rowids [0, half).
6442        let part0 = gen_batch()
6443            .col("value", array::step::<Int32Type>())
6444            .col("_rowid", array::step::<UInt64Type>())
6445            .into_df_stream(RowCount::from(half), BatchCount::from(1));
6446        train_btree_index(part0, store.as_ref(), half, None, Some(0u32))
6447            .await
6448            .unwrap();
6449
6450        // Partition 1: values/rowids [half, 2*half).
6451        let values: Vec<i32> = (half as i32..total).collect();
6452        let row_ids: Vec<u64> = (half..total as u64).collect();
6453        let part1 = gen_batch()
6454            .col("value", array::cycle::<Int32Type>(values))
6455            .col("_rowid", array::cycle::<UInt64Type>(row_ids))
6456            .into_df_stream(RowCount::from(half), BatchCount::from(1));
6457        train_btree_index(part1, store.as_ref(), half, None, Some(1u32))
6458            .await
6459            .unwrap();
6460
6461        super::merge_metadata_files(
6462            store.as_ref(),
6463            &[
6464                part_page_data_file_path(0 << 32),
6465                part_page_data_file_path(1 << 32),
6466            ],
6467            &[
6468                part_lookup_file_path(0 << 32),
6469                part_lookup_file_path(1 << 32),
6470            ],
6471            Some(1usize),
6472            noop_progress(),
6473        )
6474        .await
6475        .unwrap();
6476
6477        let index = BTreeIndex::load(store.clone(), None, &LanceCache::no_cache())
6478            .await
6479            .unwrap();
6480        assert!(
6481            index.ranges_to_files.is_some(),
6482            "test setup should produce a range-partitioned index",
6483        );
6484
6485        let cache = LanceCache::with_capacity(64 * 1024 * 1024);
6486        let plugin = BTreeIndexPlugin;
6487        plugin.put_in_cache(&cache, index.clone()).await.unwrap();
6488        let from_cache = plugin
6489            .get_from_cache(store.clone(), None, &cache)
6490            .await
6491            .unwrap()
6492            .expect("index should be served from the cache");
6493
6494        // Search a value from each range partition and confirm both paths agree.
6495        for value in [0i32, total - 1] {
6496            let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
6497            let expected = index.search(&query, &NoOpMetricsCollector).await.unwrap();
6498            let actual = from_cache
6499                .search(&query, &NoOpMetricsCollector)
6500                .await
6501                .unwrap();
6502            assert_eq!(expected, actual, "value {value}");
6503        }
6504    }
6505}