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