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 std::{
5    any::Any,
6    cmp::Ordering,
7    collections::{BTreeMap, BinaryHeap, HashMap, HashSet},
8    fmt::{Debug, Display},
9    ops::Bound,
10    sync::Arc,
11};
12
13use super::{
14    AnyQuery, BuiltinIndexType, IndexReader, IndexStore, IndexWriter, MetricsCollector,
15    OldIndexDataFilter, SargableQuery, ScalarIndex, ScalarIndexParams, SearchResult,
16    compute_next_prefix,
17};
18use crate::{Index, IndexType};
19use crate::{
20    frag_reuse::FragReuseIndex,
21    progress::{IndexBuildProgress, noop_progress},
22    scalar::{
23        CreatedIndex, UpdateCriteria,
24        expression::{SargableQueryParser, ScalarQueryParser},
25        registry::{ScalarIndexPlugin, TrainingOrdering, TrainingRequest, VALUE_COLUMN_NAME},
26    },
27};
28use crate::{metrics::NoOpMetricsCollector, scalar::registry::TrainingCriteria};
29use crate::{pbold, scalar::btree::flat::FlatIndex};
30use arrow_arith::numeric::add;
31use arrow_array::{Array, RecordBatch, UInt32Array, new_empty_array};
32use arrow_schema::{DataType, Field, Schema, SortOptions};
33use async_trait::async_trait;
34use datafusion::physical_plan::{
35    ExecutionPlan, SendableRecordBatchStream,
36    sorts::sort_preserving_merge::SortPreservingMergeExec, stream::RecordBatchStreamAdapter,
37    union::UnionExec,
38};
39use datafusion_common::{DataFusionError, ScalarValue};
40use datafusion_physical_expr::{PhysicalSortExpr, expressions::Column};
41use deepsize::DeepSizeOf;
42use futures::{
43    FutureExt, Stream, StreamExt, TryFutureExt, TryStreamExt,
44    future::BoxFuture,
45    stream::{self},
46};
47use lance_arrow::ipc::{read_ipc_stream_single_at, write_ipc_stream};
48use lance_core::{
49    Error, ROW_ID, Result,
50    cache::{CacheCodec, CacheCodecImpl, CacheKey, LanceCache, WeakLanceCache},
51    error::LanceOptionExt,
52    utils::{
53        mask::NullableRowAddrSet,
54        tokio::get_num_compute_intensive_cpus,
55        tracing::{IO_TYPE_LOAD_SCALAR_PART, TRACE_IO_EVENTS},
56    },
57};
58use lance_datafusion::{
59    chunker::chunk_concat_stream,
60    exec::{LanceExecutionOptions, OneShotExec, execute_plan},
61};
62use lance_io::object_store::ObjectStore;
63use log::{debug, warn};
64use object_store::{Error as ObjectStoreError, path::Path};
65use rangemap::RangeInclusiveMap;
66use roaring::RoaringBitmap;
67use serde::{Deserialize, Serialize, Serializer};
68use tracing::{info, instrument};
69
70mod flat;
71
72const BTREE_LOOKUP_NAME: &str = "page_lookup.lance";
73const BTREE_PAGES_NAME: &str = "page_data.lance";
74pub const DEFAULT_BTREE_BATCH_SIZE: u64 = 4096;
75const BATCH_SIZE_META_KEY: &str = "batch_size";
76const DEFAULT_RANGE_PARTITIONED: bool = false;
77const RANGE_PARTITIONED_META_KEY: &str = "range_partitioned";
78const PAGE_NUM_PER_RANGE_PARTITION_META_KEY: &str = "page_num_per_range_partition";
79const BTREE_INDEX_VERSION: u32 = 0;
80pub(crate) const BTREE_VALUES_COLUMN: &str = "values";
81pub(crate) const BTREE_IDS_COLUMN: &str = "ids";
82
83/// Wraps a ScalarValue and implements Ord (ScalarValue only implements PartialOrd)
84#[derive(Clone, Debug)]
85pub struct OrderableScalarValue(pub ScalarValue);
86
87impl DeepSizeOf for OrderableScalarValue {
88    fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
89        // deepsize and size both factor in the size of the ScalarValue
90        self.0.size() - std::mem::size_of::<ScalarValue>()
91    }
92}
93
94impl Display for OrderableScalarValue {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        std::fmt::Display::fmt(&self.0, f)
97    }
98}
99
100impl PartialEq for OrderableScalarValue {
101    fn eq(&self, other: &Self) -> bool {
102        self.0.eq(&other.0)
103    }
104}
105
106impl Eq for OrderableScalarValue {}
107
108impl PartialOrd for OrderableScalarValue {
109    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
110        Some(self.cmp(other))
111    }
112}
113
114// manual implementation of `Ord` that panics when asked to compare scalars of different type
115// and always puts nulls before non-nulls (this is consistent with Option<T>'s implementation
116// of Ord)
117//
118// TODO: Consider upstreaming this
119impl Ord for OrderableScalarValue {
120    fn cmp(&self, other: &Self) -> Ordering {
121        use ScalarValue::*;
122        // This purposely doesn't have a catch-all "(_, _)" so that
123        // any newly added enum variant will require editing this list
124        // or else face a compile error
125        match (&self.0, &other.0) {
126            (Decimal32(v1, p1, s1), Decimal32(v2, p2, s2)) => {
127                if p1.eq(p2) && s1.eq(s2) {
128                    v1.cmp(v2)
129                } else {
130                    // Two decimal values can only be compared if they have the same precision and scale.
131                    panic!("Attempt to compare decimals with unequal precision / scale")
132                }
133            }
134            (Decimal32(v1, _, _), Null) => {
135                if v1.is_none() {
136                    Ordering::Equal
137                } else {
138                    Ordering::Greater
139                }
140            }
141            (Decimal32(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
142            (Decimal64(v1, p1, s1), Decimal64(v2, p2, s2)) => {
143                if p1.eq(p2) && s1.eq(s2) {
144                    v1.cmp(v2)
145                } else {
146                    // Two decimal values can only be compared if they have the same precision and scale.
147                    panic!("Attempt to compare decimals with unequal precision / scale")
148                }
149            }
150            (Decimal64(v1, _, _), Null) => {
151                if v1.is_none() {
152                    Ordering::Equal
153                } else {
154                    Ordering::Greater
155                }
156            }
157            (Decimal64(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
158            (Decimal128(v1, p1, s1), Decimal128(v2, p2, s2)) => {
159                if p1.eq(p2) && s1.eq(s2) {
160                    v1.cmp(v2)
161                } else {
162                    // Two decimal values can only be compared if they have the same precision and scale.
163                    panic!("Attempt to compare decimals with unequal precision / scale")
164                }
165            }
166            (Decimal128(v1, _, _), Null) => {
167                if v1.is_none() {
168                    Ordering::Equal
169                } else {
170                    Ordering::Greater
171                }
172            }
173            (Decimal128(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
174            (Decimal256(v1, p1, s1), Decimal256(v2, p2, s2)) => {
175                if p1.eq(p2) && s1.eq(s2) {
176                    v1.cmp(v2)
177                } else {
178                    // Two decimal values can only be compared if they have the same precision and scale.
179                    panic!("Attempt to compare decimals with unequal precision / scale")
180                }
181            }
182            (Decimal256(v1, _, _), Null) => {
183                if v1.is_none() {
184                    Ordering::Equal
185                } else {
186                    Ordering::Greater
187                }
188            }
189            (Decimal256(_, _, _), _) => panic!("Attempt to compare decimal with non-decimal"),
190
191            (Boolean(v1), Boolean(v2)) => v1.cmp(v2),
192            (Boolean(v1), Null) => {
193                if v1.is_none() {
194                    Ordering::Equal
195                } else {
196                    Ordering::Greater
197                }
198            }
199            (Boolean(_), _) => panic!("Attempt to compare boolean with non-boolean"),
200            (Float32(v1), Float32(v2)) => match (v1, v2) {
201                (Some(f1), Some(f2)) => f1.total_cmp(f2),
202                (None, Some(_)) => Ordering::Less,
203                (Some(_), None) => Ordering::Greater,
204                (None, None) => Ordering::Equal,
205            },
206            (Float32(v1), Null) => {
207                if v1.is_none() {
208                    Ordering::Equal
209                } else {
210                    Ordering::Greater
211                }
212            }
213            (Float32(_), _) => panic!("Attempt to compare f32 with non-f32"),
214            (Float64(v1), Float64(v2)) => match (v1, v2) {
215                (Some(f1), Some(f2)) => f1.total_cmp(f2),
216                (None, Some(_)) => Ordering::Less,
217                (Some(_), None) => Ordering::Greater,
218                (None, None) => Ordering::Equal,
219            },
220            (Float64(v1), Null) => {
221                if v1.is_none() {
222                    Ordering::Equal
223                } else {
224                    Ordering::Greater
225                }
226            }
227            (Float64(_), _) => panic!("Attempt to compare f64 with non-f64"),
228            (Float16(v1), Float16(v2)) => match (v1, v2) {
229                (Some(f1), Some(f2)) => f1.total_cmp(f2),
230                (None, Some(_)) => Ordering::Less,
231                (Some(_), None) => Ordering::Greater,
232                (None, None) => Ordering::Equal,
233            },
234            (Float16(v1), Null) => {
235                if v1.is_none() {
236                    Ordering::Equal
237                } else {
238                    Ordering::Greater
239                }
240            }
241            (Float16(_), _) => panic!("Attempt to compare f16 with non-f16"),
242            (Int8(v1), Int8(v2)) => v1.cmp(v2),
243            (Int8(v1), Null) => {
244                if v1.is_none() {
245                    Ordering::Equal
246                } else {
247                    Ordering::Greater
248                }
249            }
250            (Int8(_), _) => panic!("Attempt to compare Int8 with non-Int8"),
251            (Int16(v1), Int16(v2)) => v1.cmp(v2),
252            (Int16(v1), Null) => {
253                if v1.is_none() {
254                    Ordering::Equal
255                } else {
256                    Ordering::Greater
257                }
258            }
259            (Int16(_), _) => panic!("Attempt to compare Int16 with non-Int16"),
260            (Int32(v1), Int32(v2)) => v1.cmp(v2),
261            (Int32(v1), Null) => {
262                if v1.is_none() {
263                    Ordering::Equal
264                } else {
265                    Ordering::Greater
266                }
267            }
268            (Int32(_), _) => panic!("Attempt to compare Int32 with non-Int32"),
269            (Int64(v1), Int64(v2)) => v1.cmp(v2),
270            (Int64(v1), Null) => {
271                if v1.is_none() {
272                    Ordering::Equal
273                } else {
274                    Ordering::Greater
275                }
276            }
277            (Int64(_), _) => panic!("Attempt to compare Int64 with non-Int64"),
278            (UInt8(v1), UInt8(v2)) => v1.cmp(v2),
279            (UInt8(v1), Null) => {
280                if v1.is_none() {
281                    Ordering::Equal
282                } else {
283                    Ordering::Greater
284                }
285            }
286            (UInt8(_), _) => panic!("Attempt to compare UInt8 with non-UInt8"),
287            (UInt16(v1), UInt16(v2)) => v1.cmp(v2),
288            (UInt16(v1), Null) => {
289                if v1.is_none() {
290                    Ordering::Equal
291                } else {
292                    Ordering::Greater
293                }
294            }
295            (UInt16(_), _) => panic!("Attempt to compare UInt16 with non-UInt16"),
296            (UInt32(v1), UInt32(v2)) => v1.cmp(v2),
297            (UInt32(v1), Null) => {
298                if v1.is_none() {
299                    Ordering::Equal
300                } else {
301                    Ordering::Greater
302                }
303            }
304            (UInt32(_), _) => panic!("Attempt to compare UInt32 with non-UInt32"),
305            (UInt64(v1), UInt64(v2)) => v1.cmp(v2),
306            (UInt64(v1), Null) => {
307                if v1.is_none() {
308                    Ordering::Equal
309                } else {
310                    Ordering::Greater
311                }
312            }
313            (UInt64(_), _) => panic!("Attempt to compare UInt64 with non-UInt64"),
314            (Utf8(v1) | Utf8View(v1) | LargeUtf8(v1), Utf8(v2) | Utf8View(v2) | LargeUtf8(v2)) => {
315                v1.cmp(v2)
316            }
317            (Utf8(v1) | Utf8View(v1) | LargeUtf8(v1), Null) => {
318                if v1.is_none() {
319                    Ordering::Equal
320                } else {
321                    Ordering::Greater
322                }
323            }
324            (Utf8(_) | Utf8View(_) | LargeUtf8(_), _) => {
325                panic!("Attempt to compare Utf8 with non-Utf8")
326            }
327            (
328                Binary(v1) | LargeBinary(v1) | BinaryView(v1),
329                Binary(v2) | LargeBinary(v2) | BinaryView(v2),
330            ) => v1.cmp(v2),
331            (Binary(v1) | LargeBinary(v1) | BinaryView(v1), Null) => {
332                if v1.is_none() {
333                    Ordering::Equal
334                } else {
335                    Ordering::Greater
336                }
337            }
338            (Binary(_) | LargeBinary(_) | BinaryView(_), _) => {
339                panic!("Attempt to compare Binary with non-Binary")
340            }
341            (FixedSizeBinary(_, v1), FixedSizeBinary(_, v2)) => v1.cmp(v2),
342            (FixedSizeBinary(_, v1), Null) => {
343                if v1.is_none() {
344                    Ordering::Equal
345                } else {
346                    Ordering::Greater
347                }
348            }
349            (FixedSizeBinary(_, _), _) => {
350                panic!("Attempt to compare FixedSizeBinary with non-FixedSizeBinary")
351            }
352            (FixedSizeList(left), FixedSizeList(right)) => {
353                if left.eq(right) {
354                    todo!()
355                } else {
356                    panic!(
357                        "Attempt to compare fixed size list elements with different widths/fields"
358                    )
359                }
360            }
361            (FixedSizeList(left), Null) => {
362                if left.is_null(0) {
363                    Ordering::Equal
364                } else {
365                    Ordering::Greater
366                }
367            }
368            (FixedSizeList(_), _) => {
369                panic!("Attempt to compare FixedSizeList with non-FixedSizeList")
370            }
371            (List(_), List(_)) => todo!(),
372            (List(left), Null) => {
373                if left.is_null(0) {
374                    Ordering::Equal
375                } else {
376                    Ordering::Greater
377                }
378            }
379            (List(_), _) => {
380                panic!("Attempt to compare List with non-List")
381            }
382            (LargeList(_), _) => todo!(),
383            (Map(_), Map(_)) => todo!(),
384            (Map(left), Null) => {
385                if left.is_null(0) {
386                    Ordering::Equal
387                } else {
388                    Ordering::Greater
389                }
390            }
391            (Map(_), _) => {
392                panic!("Attempt to compare Map with non-Map")
393            }
394            (Date32(v1), Date32(v2)) => v1.cmp(v2),
395            (Date32(v1), Null) => {
396                if v1.is_none() {
397                    Ordering::Equal
398                } else {
399                    Ordering::Greater
400                }
401            }
402            (Date32(_), _) => panic!("Attempt to compare Date32 with non-Date32"),
403            (Date64(v1), Date64(v2)) => v1.cmp(v2),
404            (Date64(v1), Null) => {
405                if v1.is_none() {
406                    Ordering::Equal
407                } else {
408                    Ordering::Greater
409                }
410            }
411            (Date64(_), _) => panic!("Attempt to compare Date64 with non-Date64"),
412            (Time32Second(v1), Time32Second(v2)) => v1.cmp(v2),
413            (Time32Second(v1), Null) => {
414                if v1.is_none() {
415                    Ordering::Equal
416                } else {
417                    Ordering::Greater
418                }
419            }
420            (Time32Second(_), _) => panic!("Attempt to compare Time32Second with non-Time32Second"),
421            (Time32Millisecond(v1), Time32Millisecond(v2)) => v1.cmp(v2),
422            (Time32Millisecond(v1), Null) => {
423                if v1.is_none() {
424                    Ordering::Equal
425                } else {
426                    Ordering::Greater
427                }
428            }
429            (Time32Millisecond(_), _) => {
430                panic!("Attempt to compare Time32Millisecond with non-Time32Millisecond")
431            }
432            (Time64Microsecond(v1), Time64Microsecond(v2)) => v1.cmp(v2),
433            (Time64Microsecond(v1), Null) => {
434                if v1.is_none() {
435                    Ordering::Equal
436                } else {
437                    Ordering::Greater
438                }
439            }
440            (Time64Microsecond(_), _) => {
441                panic!("Attempt to compare Time64Microsecond with non-Time64Microsecond")
442            }
443            (Time64Nanosecond(v1), Time64Nanosecond(v2)) => v1.cmp(v2),
444            (Time64Nanosecond(v1), Null) => {
445                if v1.is_none() {
446                    Ordering::Equal
447                } else {
448                    Ordering::Greater
449                }
450            }
451            (Time64Nanosecond(_), _) => {
452                panic!("Attempt to compare Time64Nanosecond with non-Time64Nanosecond")
453            }
454            (TimestampSecond(v1, _), TimestampSecond(v2, _)) => v1.cmp(v2),
455            (TimestampSecond(v1, _), Null) => {
456                if v1.is_none() {
457                    Ordering::Equal
458                } else {
459                    Ordering::Greater
460                }
461            }
462            (TimestampSecond(_, _), _) => {
463                panic!("Attempt to compare TimestampSecond with non-TimestampSecond")
464            }
465            (TimestampMillisecond(v1, _), TimestampMillisecond(v2, _)) => v1.cmp(v2),
466            (TimestampMillisecond(v1, _), Null) => {
467                if v1.is_none() {
468                    Ordering::Equal
469                } else {
470                    Ordering::Greater
471                }
472            }
473            (TimestampMillisecond(_, _), _) => {
474                panic!("Attempt to compare TimestampMillisecond with non-TimestampMillisecond")
475            }
476            (TimestampMicrosecond(v1, _), TimestampMicrosecond(v2, _)) => v1.cmp(v2),
477            (TimestampMicrosecond(v1, _), Null) => {
478                if v1.is_none() {
479                    Ordering::Equal
480                } else {
481                    Ordering::Greater
482                }
483            }
484            (TimestampMicrosecond(_, _), _) => {
485                panic!("Attempt to compare TimestampMicrosecond with non-TimestampMicrosecond")
486            }
487            (TimestampNanosecond(v1, _), TimestampNanosecond(v2, _)) => v1.cmp(v2),
488            (TimestampNanosecond(v1, _), Null) => {
489                if v1.is_none() {
490                    Ordering::Equal
491                } else {
492                    Ordering::Greater
493                }
494            }
495            (TimestampNanosecond(_, _), _) => {
496                panic!("Attempt to compare TimestampNanosecond with non-TimestampNanosecond")
497            }
498            (IntervalYearMonth(v1), IntervalYearMonth(v2)) => v1.cmp(v2),
499            (IntervalYearMonth(v1), Null) => {
500                if v1.is_none() {
501                    Ordering::Equal
502                } else {
503                    Ordering::Greater
504                }
505            }
506            (IntervalYearMonth(_), _) => {
507                panic!("Attempt to compare IntervalYearMonth with non-IntervalYearMonth")
508            }
509            (IntervalDayTime(v1), IntervalDayTime(v2)) => v1.cmp(v2),
510            (IntervalDayTime(v1), Null) => {
511                if v1.is_none() {
512                    Ordering::Equal
513                } else {
514                    Ordering::Greater
515                }
516            }
517            (IntervalDayTime(_), _) => {
518                panic!("Attempt to compare IntervalDayTime with non-IntervalDayTime")
519            }
520            (IntervalMonthDayNano(v1), IntervalMonthDayNano(v2)) => v1.cmp(v2),
521            (IntervalMonthDayNano(v1), Null) => {
522                if v1.is_none() {
523                    Ordering::Equal
524                } else {
525                    Ordering::Greater
526                }
527            }
528            (IntervalMonthDayNano(_), _) => {
529                panic!("Attempt to compare IntervalMonthDayNano with non-IntervalMonthDayNano")
530            }
531            (DurationSecond(v1), DurationSecond(v2)) => v1.cmp(v2),
532            (DurationSecond(v1), Null) => {
533                if v1.is_none() {
534                    Ordering::Equal
535                } else {
536                    Ordering::Greater
537                }
538            }
539            (DurationSecond(_), _) => {
540                panic!("Attempt to compare DurationSecond with non-DurationSecond")
541            }
542            (DurationMillisecond(v1), DurationMillisecond(v2)) => v1.cmp(v2),
543            (DurationMillisecond(v1), Null) => {
544                if v1.is_none() {
545                    Ordering::Equal
546                } else {
547                    Ordering::Greater
548                }
549            }
550            (DurationMillisecond(_), _) => {
551                panic!("Attempt to compare DurationMillisecond with non-DurationMillisecond")
552            }
553            (DurationMicrosecond(v1), DurationMicrosecond(v2)) => v1.cmp(v2),
554            (DurationMicrosecond(v1), Null) => {
555                if v1.is_none() {
556                    Ordering::Equal
557                } else {
558                    Ordering::Greater
559                }
560            }
561            (DurationMicrosecond(_), _) => {
562                panic!("Attempt to compare DurationMicrosecond with non-DurationMicrosecond")
563            }
564            (DurationNanosecond(v1), DurationNanosecond(v2)) => v1.cmp(v2),
565            (DurationNanosecond(v1), Null) => {
566                if v1.is_none() {
567                    Ordering::Equal
568                } else {
569                    Ordering::Greater
570                }
571            }
572            (DurationNanosecond(_), _) => {
573                panic!("Attempt to compare DurationNanosecond with non-DurationNanosecond")
574            }
575            (Struct(_arr), Struct(_arr2)) => todo!(),
576            (Struct(arr), Null) => {
577                if arr.is_empty() {
578                    Ordering::Equal
579                } else {
580                    Ordering::Greater
581                }
582            }
583            (Struct(_arr), _) => panic!("Attempt to compare Struct with non-Struct"),
584            (Dictionary(_k1, _v1), Dictionary(_k2, _v2)) => todo!(),
585            (Dictionary(_, v1), Null) => Self(*v1.clone()).cmp(&Self(ScalarValue::Null)),
586            (Dictionary(_, _), _) => panic!("Attempt to compare Dictionary with non-Dictionary"),
587            // What would a btree of unions even look like?  May not be possible.
588            (Union(_, _, _), _) => todo!("Support for union scalars"),
589            (RunEndEncoded(_, _, _), _) => {
590                todo!("Support for run-end encoded scalars")
591            }
592            (Null, Null) => Ordering::Equal,
593            (Null, _) => todo!(),
594        }
595    }
596}
597
598#[derive(Debug, DeepSizeOf, PartialEq, Eq)]
599struct PageRecord {
600    max: OrderableScalarValue,
601    page_number: u32,
602}
603
604trait BTreeMapExt<K, V> {
605    fn largest_node_less(&self, key: &K) -> Option<(&K, &V)>;
606}
607
608impl<K: Ord, V> BTreeMapExt<K, V> for BTreeMap<K, V> {
609    fn largest_node_less(&self, key: &K) -> Option<(&K, &V)> {
610        self.range((Bound::Unbounded, Bound::Excluded(key)))
611            .next_back()
612    }
613}
614
615/// An in-memory structure that can quickly satisfy scalar queries using a btree of ScalarValue
616#[derive(Debug, DeepSizeOf, PartialEq, Eq)]
617pub struct BTreeLookup {
618    tree: BTreeMap<OrderableScalarValue, Vec<PageRecord>>,
619    /// Pages where the value may be null (does not include all_null_pages)
620    null_pages: Vec<u32>,
621    /// Pages that are entirely null
622    all_null_pages: Vec<u32>,
623}
624
625impl BTreeLookup {
626    fn empty() -> Self {
627        Self {
628            tree: BTreeMap::new(),
629            null_pages: Vec::new(),
630            all_null_pages: Vec::new(),
631        }
632    }
633}
634
635#[derive(Debug, Copy, Clone)]
636enum Matches {
637    Some(u32),
638    All(u32),
639}
640
641impl Matches {
642    fn page_id(&self) -> u32 {
643        match self {
644            Self::Some(page_id) => *page_id,
645            Self::All(page_id) => *page_id,
646        }
647    }
648}
649
650impl BTreeLookup {
651    fn new(
652        tree: BTreeMap<OrderableScalarValue, Vec<PageRecord>>,
653        null_pages: Vec<u32>,
654        all_null_pages: Vec<u32>,
655    ) -> Self {
656        Self {
657            tree,
658            null_pages,
659            all_null_pages,
660        }
661    }
662
663    // All pages that could have a value equal to val
664    fn pages_eq(&self, query: &OrderableScalarValue) -> Vec<Matches> {
665        if query.0.is_null() {
666            self.pages_null()
667        } else {
668            self.pages_between((Bound::Included(query), Bound::Excluded(query)))
669        }
670    }
671
672    // All pages that could have a value equal to one of the values
673    fn pages_in(&self, values: impl IntoIterator<Item = OrderableScalarValue>) -> Vec<Matches> {
674        // TODO: Right now we convert all Matches::All into Matches::Some.  We could refine this.
675        // It would improve performance on low cardinality data.
676        let page_lists = values
677            .into_iter()
678            .map(|val| {
679                self.pages_eq(&val)
680                    .into_iter()
681                    .map(|matches| matches.page_id())
682            })
683            .collect::<Vec<_>>();
684        let total_size = page_lists.iter().map(|set| set.len()).sum();
685        let mut heap = BinaryHeap::with_capacity(total_size);
686        for page_list in page_lists {
687            heap.extend(page_list);
688        }
689        let mut all_pages = heap.into_sorted_vec();
690        all_pages.dedup();
691        all_pages.into_iter().map(Matches::Some).collect()
692    }
693
694    // All pages that could have a value in the range
695    fn pages_between(
696        &self,
697        range: (Bound<&OrderableScalarValue>, Bound<&OrderableScalarValue>),
698    ) -> Vec<Matches> {
699        // We need to grab a little bit left of the given range because the query might be 7
700        // and the first page might be something like 5-10.
701        let lower_bound = match range.0 {
702            Bound::Unbounded => Bound::Unbounded,
703            // It doesn't matter if the bound is exclusive or inclusive.  We are going to grab
704            // the first node whose min is strictly less than the given bound.  Then we grab
705            // all nodes greater than or equal to that
706            //
707            // We have to peek a bit to the left because we might have something like a lower
708            // bound of 7 and there is a page [5-10] we want to search for.
709            Bound::Included(lower) => self
710                .tree
711                .largest_node_less(lower)
712                .map(|val| Bound::Included(val.0))
713                .unwrap_or(Bound::Unbounded),
714            Bound::Excluded(lower) => self
715                .tree
716                .largest_node_less(lower)
717                .map(|val| Bound::Included(val.0))
718                .unwrap_or(Bound::Unbounded),
719        };
720        let upper_bound = match range.1 {
721            Bound::Unbounded => Bound::Unbounded,
722            Bound::Included(upper) => Bound::Included(upper),
723            // Even if the upper bound is excluded we need to include it on an [x, x) query.  This is because the
724            // query might be [x, x).  Our lower bound might find some [a-x] bucket and we still
725            // want to include any [x, z] bucket.
726            //
727            // We could be slightly more accurate here and only include the upper bound if the lower bound
728            // is defined, inclusive, and equal to the upper bound.  However, let's keep it simple for now.  This
729            // should only affect the probably rare case that our query is a true range query and the value
730            // matches an upper bound.  This will all be moot if/when we merge pages.
731            Bound::Excluded(upper) => Bound::Included(upper),
732        };
733
734        match (lower_bound, upper_bound) {
735            (Bound::Excluded(lower), Bound::Excluded(upper))
736            | (Bound::Excluded(lower), Bound::Included(upper))
737            | (Bound::Included(lower), Bound::Excluded(upper)) => {
738                // It's not really clear what (Included(5), Excluded(5)) would mean so we
739                // interpret it as an empty range which matches rust's BTreeMap behavior
740                if lower >= upper {
741                    return vec![];
742                }
743            }
744            (Bound::Included(lower), Bound::Included(upper)) => {
745                if lower > upper {
746                    return vec![];
747                }
748            }
749            _ => {}
750        }
751
752        let mut matches = Vec::new();
753
754        for (min, page_records) in self.tree.range((lower_bound, upper_bound)) {
755            for page_record in page_records {
756                match lower_bound {
757                    Bound::Unbounded => {}
758                    Bound::Included(lower) => {
759                        if page_record.max.cmp(lower) == Ordering::Less {
760                            continue;
761                        }
762                    }
763                    Bound::Excluded(lower) => {
764                        if page_record.max.cmp(lower) != Ordering::Greater {
765                            continue;
766                        }
767                    }
768                }
769                // At this point we know the page record matches at least some values.
770                // We should test to see if ALL values are a match.
771
772                if min.0.is_null() || page_record.max.0.is_null() {
773                    // If there are nulls then we just use Matches::Some
774                    matches.push(Matches::Some(page_record.page_number));
775                    continue;
776                }
777
778                match range.0 {
779                    // range.0 < X therefore if the smallest value is not strictly greater than
780                    // the lower bound we only have partial match
781                    Bound::Excluded(lower) => {
782                        if min.cmp(lower) != Ordering::Greater {
783                            matches.push(Matches::Some(page_record.page_number));
784                            continue;
785                        }
786                    }
787                    // range.0 <= X therefore if the smallest value is not greater than or equal
788                    // to the lower bound we only have partial match
789                    Bound::Included(lower) => {
790                        if min.cmp(lower) == Ordering::Less {
791                            matches.push(Matches::Some(page_record.page_number));
792                            continue;
793                        }
794                    }
795                    Bound::Unbounded => {}
796                }
797                match range.1 {
798                    // X < range.1 therefore if the largest value is not strictly less than
799                    // the upper bound we only have partial match
800                    Bound::Excluded(upper) => {
801                        if page_record.max.cmp(upper) != Ordering::Less {
802                            matches.push(Matches::Some(page_record.page_number));
803                            continue;
804                        }
805                    }
806                    // X <= range.1 therefore if the largest value is not less than or equal to
807                    // the upper bound we only have partial match
808                    Bound::Included(upper) => {
809                        if page_record.max.cmp(upper) == Ordering::Greater {
810                            matches.push(Matches::Some(page_record.page_number));
811                            continue;
812                        }
813                    }
814                    Bound::Unbounded => {}
815                }
816                // The min is greater than the lower bound and the max is less than the upper bound
817                // so we have a full match
818                matches.push(Matches::All(page_record.page_number));
819            }
820        }
821
822        matches
823    }
824
825    fn pages_null(&self) -> Vec<Matches> {
826        self.null_pages
827            .iter()
828            .map(|page_id| Matches::Some(*page_id))
829            .chain(self.all_null_pages.iter().copied().map(Matches::All))
830            .collect()
831    }
832}
833
834// We only need to open a file reader for pages if we need to load a page.  If all
835// pages are cached we don't open it.  If we do open it we should only open it once.
836#[derive(Clone)]
837struct LazyIndexReader {
838    index_reader: Arc<tokio::sync::Mutex<Option<Arc<dyn IndexReader>>>>,
839    store: Arc<dyn IndexStore>,
840    ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
841}
842
843impl LazyIndexReader {
844    fn new(
845        store: Arc<dyn IndexStore>,
846        ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
847    ) -> Self {
848        Self {
849            index_reader: Arc::new(tokio::sync::Mutex::new(None)),
850            store,
851            ranges_to_files,
852        }
853    }
854
855    async fn get(&self) -> Result<Arc<dyn IndexReader>> {
856        let mut reader = self.index_reader.lock().await;
857        if reader.is_none() {
858            let index_reader = if let Some(ranges_to_files) = &self.ranges_to_files {
859                Arc::new(LazyRangedIndexReader::new(
860                    self.store.clone(),
861                    ranges_to_files.clone(),
862                ))
863            } else {
864                self.store.open_index_file(BTREE_PAGES_NAME).await?
865            };
866            *reader = Some(index_reader);
867        }
868        Ok(reader.as_ref().unwrap().clone())
869    }
870}
871
872/// Index reader to dispatch page query to corresponding ranged page-files.
873struct LazyRangedIndexReader {
874    #[allow(clippy::type_complexity)]
875    readers:
876        Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::OnceCell<Arc<dyn IndexReader>>>>>>,
877    store: Arc<dyn IndexStore>,
878    ranges_to_files: Arc<RangeInclusiveMap<u32, (String, u32)>>,
879}
880
881impl LazyRangedIndexReader {
882    fn new(
883        store: Arc<dyn IndexStore>,
884        ranges_to_files: Arc<RangeInclusiveMap<u32, (String, u32)>>,
885    ) -> Self {
886        Self {
887            readers: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
888            store,
889            ranges_to_files,
890        }
891    }
892
893    async fn get_reader(&self, file_name: &str) -> Result<Arc<dyn IndexReader>> {
894        let reader_cell = {
895            let mut guard = self.readers.lock().await;
896            guard
897                .entry(file_name.to_string())
898                .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
899                .clone()
900        };
901        let reader = reader_cell
902            .get_or_try_init(|| async { self.store.open_index_file(file_name).await })
903            .await?;
904        Ok(reader.clone())
905    }
906
907    async fn get_reader_and_local_page_idx(
908        &self,
909        page_idx: u32,
910    ) -> Result<(Arc<dyn IndexReader>, u32)> {
911        let (page_file_name, offset) = self.ranges_to_files.get(&page_idx).ok_or_else(|| {
912            Error::internal(format!(
913                "Unexpected page index, index {} is out of range.",
914                page_idx
915            ))
916        })?;
917        let reader = self.get_reader(page_file_name).await?;
918        Ok((reader.clone(), page_idx - *offset))
919    }
920}
921
922#[async_trait]
923impl IndexReader for LazyRangedIndexReader {
924    async fn read_record_batch(&self, n: u64, batch_size: u64) -> Result<RecordBatch> {
925        let (reader, local_page_idx) = self.get_reader_and_local_page_idx(n as u32).await?;
926        reader
927            .read_record_batch(local_page_idx as u64, batch_size)
928            .await
929    }
930
931    async fn read_range(
932        &self,
933        _range: std::ops::Range<usize>,
934        _projection: Option<&[&str]>,
935    ) -> Result<RecordBatch> {
936        unimplemented!("Read range is not implemented for lazy page file reader.");
937    }
938
939    async fn num_batches(&self, batch_size: u64) -> u32 {
940        let mut total_batches = 0;
941        for (_, (file_name, _)) in self.ranges_to_files.iter() {
942            let reader = self
943                .get_reader(file_name)
944                .await
945                .unwrap_or_else(|_| panic!("Cannot open page file {}.", file_name));
946            total_batches += reader.as_ref().num_batches(batch_size).await;
947        }
948        total_batches
949    }
950
951    fn num_rows(&self) -> usize {
952        unimplemented!("only async functions are available for lazy page index reader.");
953    }
954
955    fn schema(&self) -> &lance_core::datatypes::Schema {
956        unimplemented!("only async functions are available for lazy page index reader.");
957    }
958}
959
960/// A btree index satisfies scalar queries using a b tree
961///
962/// The upper layers of the btree are expected to be cached and, when unloaded,
963/// are stored in a btree structure in memory.  The leaves of the btree are left
964/// to be searched by some other kind of index (currently a flat search).
965///
966/// This strikes a balance between an expensive memory structure containing all
967/// of the values and an expensive disk structure that can't be efficiently searched.
968///
969/// For example, given 1Bi values we can store 256Ki leaves of size 4Ki.  We only
970/// need memory space for 256Ki leaves (depends on the data type but usually a few MiB
971/// at most) and can narrow our search to 4Ki values.
972///
973// Cache key implementation for type-safe cache access
974#[derive(Debug, Clone, DeepSizeOf)]
975pub struct CachedScalarIndex(Arc<dyn ScalarIndex>);
976
977impl CachedScalarIndex {
978    pub fn new(index: Arc<dyn ScalarIndex>) -> Self {
979        Self(index)
980    }
981
982    pub fn into_inner(self) -> Arc<dyn ScalarIndex> {
983        self.0
984    }
985}
986
987#[derive(Debug, Clone)]
988pub struct BTreePageKey {
989    pub page_number: u32,
990}
991
992impl CacheKey for BTreePageKey {
993    type ValueType = FlatIndex;
994
995    fn key(&self) -> std::borrow::Cow<'_, str> {
996        format!("page-{}", self.page_number).into()
997    }
998
999    fn type_name() -> &'static str {
1000        "BTreePage"
1001    }
1002
1003    fn codec() -> Option<CacheCodec> {
1004        // Pages are cached as `FlatIndex` values (see `ValueType` above).
1005        Some(CacheCodec::from_impl::<FlatIndex>())
1006    }
1007}
1008
1009/// The serializable state of a [`BTreeIndex`].
1010///
1011/// A `BTreeIndex` holds non-serializable infrastructure (an `IndexStore`, a
1012/// cache handle, a fragment-reuse index). `BTreeIndexState` captures just the
1013/// data needed to rebuild it: the `page_lookup.lance` batch (from which
1014/// `BTreeIndex::try_from_serialized` reconstructs the in-memory lookup with
1015/// no IO) plus the page batch size and range-partition map.
1016#[derive(Debug, Clone)]
1017pub struct BTreeIndexState {
1018    lookup_batch: RecordBatch,
1019    batch_size: u64,
1020    ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1021}
1022
1023impl DeepSizeOf for BTreeIndexState {
1024    fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
1025        // `ranges_to_files` is tiny and `RangeInclusiveMap` is not `DeepSizeOf`;
1026        // the lookup batch dominates, matching how `BTreeIndex` accounts for itself.
1027        self.lookup_batch.get_array_memory_size()
1028    }
1029}
1030
1031impl BTreeIndexState {
1032    fn reconstruct(
1033        &self,
1034        store: Arc<dyn IndexStore>,
1035        index_cache: &LanceCache,
1036        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1037    ) -> Result<Arc<dyn ScalarIndex>> {
1038        let index = BTreeIndex::try_from_serialized(
1039            self.lookup_batch.clone(),
1040            store,
1041            index_cache,
1042            self.batch_size,
1043            self.ranges_to_files.clone(),
1044            frag_reuse_index,
1045        )?;
1046        Ok(Arc::new(index) as Arc<dyn ScalarIndex>)
1047    }
1048}
1049
1050impl CacheCodecImpl for BTreeIndexState {
1051    /// Wire format (no stability guarantees yet — the cache is rebuilt from
1052    /// source on any version mismatch):
1053    /// ```text
1054    /// u64 batch_size (LE)
1055    /// u8  has_ranges (0 = None, 1 = Some)
1056    /// if has_ranges:
1057    ///   u32 entry_count (LE)
1058    ///   per entry: u32 start | u32 end | u32 offset | u32 path_len | path bytes
1059    /// lookup batch (Arrow IPC stream)
1060    /// ```
1061    fn serialize(&self, writer: &mut dyn std::io::Write) -> Result<()> {
1062        writer.write_all(&self.batch_size.to_le_bytes())?;
1063        match &self.ranges_to_files {
1064            None => writer.write_all(&[0u8])?,
1065            Some(ranges) => {
1066                writer.write_all(&[1u8])?;
1067                let count = u32::try_from(ranges.len()).map_err(|_| {
1068                    Error::io("BTreeIndexState: ranges_to_files exceeds u32::MAX entries")
1069                })?;
1070                writer.write_all(&count.to_le_bytes())?;
1071                for (range, (path, page_offset)) in ranges.iter() {
1072                    writer.write_all(&range.start().to_le_bytes())?;
1073                    writer.write_all(&range.end().to_le_bytes())?;
1074                    writer.write_all(&page_offset.to_le_bytes())?;
1075                    let path_len = u32::try_from(path.len()).map_err(|_| {
1076                        Error::io("BTreeIndexState: ranges_to_files path exceeds u32::MAX bytes")
1077                    })?;
1078                    writer.write_all(&path_len.to_le_bytes())?;
1079                    writer.write_all(path.as_bytes())?;
1080                }
1081            }
1082        }
1083        write_ipc_stream(&self.lookup_batch, writer)?;
1084        Ok(())
1085    }
1086
1087    fn deserialize(data: &bytes::Bytes) -> Result<Self> {
1088        let mut offset = 0;
1089        let batch_size = read_u64_le(data, &mut offset)?;
1090        let has_ranges = read_u8(data, &mut offset)?;
1091        let ranges_to_files = match has_ranges {
1092            0 => None,
1093            1 => {
1094                let count = read_u32_le(data, &mut offset)? as usize;
1095                let mut entries = Vec::with_capacity(count);
1096                for _ in 0..count {
1097                    let start = read_u32_le(data, &mut offset)?;
1098                    let end = read_u32_le(data, &mut offset)?;
1099                    let page_offset = read_u32_le(data, &mut offset)?;
1100                    let path_len = read_u32_le(data, &mut offset)? as usize;
1101                    let path = read_bytes(data, &mut offset, path_len)?;
1102                    let path = std::str::from_utf8(&path)
1103                        .map_err(|e| Error::io(format!("BTreeIndexState path: {e}")))?
1104                        .to_string();
1105                    entries.push((start..=end, (path, page_offset)));
1106                }
1107                Some(Arc::new(entries.into_iter().collect()))
1108            }
1109            other => {
1110                return Err(Error::io(format!(
1111                    "BTreeIndexState: invalid has_ranges tag {other}"
1112                )));
1113            }
1114        };
1115        let lookup_batch = read_ipc_stream_single_at(data, &mut offset)?;
1116        Ok(Self {
1117            lookup_batch,
1118            batch_size,
1119            ranges_to_files,
1120        })
1121    }
1122}
1123
1124fn read_bytes(data: &bytes::Bytes, offset: &mut usize, len: usize) -> Result<bytes::Bytes> {
1125    if data.len() < *offset + len {
1126        return Err(Error::io(format!(
1127            "BTreeIndexState: short read of {len} bytes at offset {offset} (have {})",
1128            data.len()
1129        )));
1130    }
1131    let slice = data.slice(*offset..*offset + len);
1132    *offset += len;
1133    Ok(slice)
1134}
1135
1136fn read_u8(data: &bytes::Bytes, offset: &mut usize) -> Result<u8> {
1137    let bytes = read_bytes(data, offset, 1)?;
1138    Ok(bytes[0])
1139}
1140
1141fn read_u32_le(data: &bytes::Bytes, offset: &mut usize) -> Result<u32> {
1142    let bytes = read_bytes(data, offset, 4)?;
1143    Ok(u32::from_le_bytes(bytes.as_ref().try_into().unwrap()))
1144}
1145
1146fn read_u64_le(data: &bytes::Bytes, offset: &mut usize) -> Result<u64> {
1147    let bytes = read_bytes(data, offset, 8)?;
1148    Ok(u64::from_le_bytes(bytes.as_ref().try_into().unwrap()))
1149}
1150
1151/// Cache key for a [`BTreeIndexState`]. The cache it is used with is already
1152/// namespaced per-index, so the key string is a constant.
1153struct BTreeIndexStateKey;
1154
1155impl CacheKey for BTreeIndexStateKey {
1156    type ValueType = BTreeIndexState;
1157
1158    fn key(&self) -> std::borrow::Cow<'_, str> {
1159        "state".into()
1160    }
1161
1162    fn type_name() -> &'static str {
1163        "BTreeIndexState"
1164    }
1165
1166    fn codec() -> Option<CacheCodec> {
1167        Some(CacheCodec::from_impl::<BTreeIndexState>())
1168    }
1169}
1170
1171/// Note: this is very similar to the IVF index except we store the IVF part in a btree
1172/// for faster lookup
1173#[derive(Clone, Debug)]
1174pub struct BTreeIndex {
1175    page_lookup: Arc<BTreeLookup>,
1176    index_cache: WeakLanceCache,
1177    store: Arc<dyn IndexStore>,
1178    data_type: DataType,
1179    batch_size: u64,
1180
1181    /// A map that translates a global_page_idx stored in the single lookup file into the
1182    /// specific page file and local_page_idx.
1183    ///
1184    /// This is the key data structure used for efficiently reading data from a merged,
1185    /// range-partitioned index. It stores mappings from a contiguous range of global page
1186    /// indices to a tuple containing:
1187    ///
1188    /// 1. The path to the corresponding page file (e.g., `part_i_page_file.lance`).
1189    /// 2. The start offset that was used to calculate the local_page_idx for that partition.
1190    ///
1191    /// When a query needs to access a specific page using its `global_page_idx`:
1192    ///
1193    /// 1. The `global_page_idx` is used to look up its range in this `RangeInclusiveMap`,
1194    ///    and the map returns the `(file_path, start_offset)` tuple for that range.
1195    /// 3. The `local_page_idx` is calculated using the formula:
1196    ///    `local_page_idx = global_page_idx - start_offset`.
1197    /// 4. With the `file_path` and `local_page_idx`, the system can directly open the
1198    ///    correct partition file and read the specific page.
1199    ///
1200    /// # Example
1201    ///
1202    /// If the map contains an entry `(100..=199) => ("part_2_page_file.lance", 100)`, and we
1203    /// need to find `global_page_idx = 142`:
1204    ///
1205    /// - The map finds that 142 falls within the range `100..=199`, and it returns
1206    ///   `("part_2_page_file.lance", 100)`.
1207    /// - The local page_idx is calculated: `142 - 100 = 42`.
1208    /// - The system now knows to read page `42` from the file `part_2_page_file.lance`.
1209    ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1210    frag_reuse_index: Option<Arc<FragReuseIndex>>,
1211
1212    /// The raw lookup batch this index was built from (the contents of
1213    /// `page_lookup.lance`). Retained so the index can be serialized into a
1214    /// cache as a [`BTreeIndexState`] without re-reading it from storage.
1215    ///
1216    /// TODO: this duplicates the min/max values already held in `page_lookup`.
1217    /// A follow-up could rewrite `BTreeLookup` to query this batch directly
1218    /// (binary search on the sorted `min` column + linear scan, type-dispatched
1219    /// per column type), eliminating the duplication and making this batch the
1220    /// single source of truth.
1221    lookup_batch: RecordBatch,
1222}
1223
1224impl DeepSizeOf for BTreeIndex {
1225    fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize {
1226        // We don't include the index cache, or anything stored in it. For example:
1227        // sub_index and fri.
1228        self.page_lookup.deep_size_of_children(context)
1229            + self.store.deep_size_of_children(context)
1230            + self.lookup_batch.get_array_memory_size()
1231    }
1232}
1233
1234impl BTreeIndex {
1235    #[allow(clippy::too_many_arguments)]
1236    fn new(
1237        page_lookup: Arc<BTreeLookup>,
1238        store: Arc<dyn IndexStore>,
1239        data_type: DataType,
1240        index_cache: WeakLanceCache,
1241        batch_size: u64,
1242        ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1243        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1244        lookup_batch: RecordBatch,
1245    ) -> Self {
1246        Self {
1247            page_lookup,
1248            store,
1249            data_type,
1250            index_cache,
1251            batch_size,
1252            ranges_to_files,
1253            frag_reuse_index,
1254            lookup_batch,
1255        }
1256    }
1257
1258    async fn lookup_page(
1259        &self,
1260        page_number: u32,
1261        index_reader: LazyIndexReader,
1262        metrics: &dyn MetricsCollector,
1263    ) -> Result<Arc<FlatIndex>> {
1264        self.index_cache
1265            .get_or_insert_with_key(BTreePageKey { page_number }, move || async move {
1266                self.read_page(page_number, index_reader, metrics).await
1267            })
1268            .await
1269    }
1270
1271    #[instrument(level = "debug", skip_all)]
1272    async fn read_page(
1273        &self,
1274        page_number: u32,
1275        index_reader: LazyIndexReader,
1276        metrics: &dyn MetricsCollector,
1277    ) -> Result<FlatIndex> {
1278        metrics.record_part_load();
1279        info!(target: TRACE_IO_EVENTS, r#type=IO_TYPE_LOAD_SCALAR_PART, index_type="btree", part_id=page_number);
1280        let index_reader = index_reader.get().await?;
1281        let mut serialized_page = index_reader
1282            .read_record_batch(page_number as u64, self.batch_size)
1283            .await?;
1284        if let Some(frag_reuse_index_ref) = self.frag_reuse_index.as_ref() {
1285            serialized_page =
1286                frag_reuse_index_ref.remap_row_ids_record_batch(serialized_page, 1)?;
1287        }
1288        FlatIndex::try_new(serialized_page)
1289    }
1290
1291    async fn search_page(
1292        &self,
1293        query: &SargableQuery,
1294        matches: Matches,
1295        index_reader: LazyIndexReader,
1296        metrics: &dyn MetricsCollector,
1297    ) -> Result<NullableRowAddrSet> {
1298        let subindex = self
1299            .lookup_page(matches.page_id(), index_reader, metrics)
1300            .await?;
1301
1302        match matches {
1303            Matches::Some(_) => {
1304                // TODO: If this is an IN query we can perhaps simplify the subindex query by restricting it to the
1305                // values that might be in the page.  E.g. if we are searching for X IN [5, 3, 7] and five is in pages
1306                // 1 and 2 and three is in page 2 and seven is in pages 8 and 9, then when searching page 2 we only need
1307                // to search for X IN [5, 3]
1308                subindex.search(query, metrics)
1309            }
1310            Matches::All(_) => Ok(match query {
1311                // This means we hit an all-null page so just grab all row ids as true
1312                SargableQuery::IsNull() => subindex.all_ignore_nulls(),
1313                _ => subindex.all(),
1314            }),
1315        }
1316    }
1317
1318    #[instrument(level = "debug", skip_all)]
1319    fn try_from_serialized(
1320        data: RecordBatch,
1321        store: Arc<dyn IndexStore>,
1322        index_cache: &LanceCache,
1323        batch_size: u64,
1324        ranges_to_files: Option<Arc<RangeInclusiveMap<u32, (String, u32)>>>,
1325        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1326    ) -> Result<Self> {
1327        let mut map = BTreeMap::<OrderableScalarValue, Vec<PageRecord>>::new();
1328        // Pages that have at least one null value
1329        let mut null_pages = Vec::<u32>::new();
1330        // Pages that are entirely null
1331        let mut all_null_pages = Vec::<u32>::new();
1332
1333        if data.num_rows() == 0 {
1334            let data_type = data.column(0).data_type().clone();
1335            let page_lookup = Arc::new(BTreeLookup::empty());
1336            return Ok(Self::new(
1337                page_lookup,
1338                store,
1339                data_type,
1340                WeakLanceCache::from(index_cache),
1341                batch_size,
1342                ranges_to_files,
1343                frag_reuse_index,
1344                data,
1345            ));
1346        }
1347
1348        let mins = data.column(0);
1349        let maxs = data.column(1);
1350        let null_counts = data
1351            .column(2)
1352            .as_any()
1353            .downcast_ref::<UInt32Array>()
1354            .unwrap();
1355        let page_numbers = data
1356            .column(3)
1357            .as_any()
1358            .downcast_ref::<UInt32Array>()
1359            .unwrap();
1360
1361        for idx in 0..data.num_rows() {
1362            let min = OrderableScalarValue(ScalarValue::try_from_array(&mins, idx)?);
1363            let max = OrderableScalarValue(ScalarValue::try_from_array(&maxs, idx)?);
1364            let null_count = null_counts.values()[idx];
1365            let page_number = page_numbers.values()[idx];
1366
1367            // If the page is entirely null don't even bother putting it in the tree
1368            if max.0.is_null() {
1369                all_null_pages.push(page_number);
1370                // continue so we don't add it to the null_pages
1371                continue;
1372            } else {
1373                map.entry(min)
1374                    .or_default()
1375                    .push(PageRecord { max, page_number });
1376            }
1377
1378            if null_count > 0 {
1379                null_pages.push(page_number);
1380            }
1381        }
1382
1383        let last_max = ScalarValue::try_from_array(&maxs, data.num_rows() - 1)?;
1384        map.entry(OrderableScalarValue(last_max)).or_default();
1385
1386        let data_type = mins.data_type().clone();
1387
1388        let page_lookup = Arc::new(BTreeLookup::new(map, null_pages, all_null_pages));
1389
1390        Ok(Self::new(
1391            page_lookup,
1392            store,
1393            data_type,
1394            WeakLanceCache::from(index_cache),
1395            batch_size,
1396            ranges_to_files,
1397            frag_reuse_index,
1398            data,
1399        ))
1400    }
1401
1402    async fn load(
1403        store: Arc<dyn IndexStore>,
1404        frag_reuse_index: Option<Arc<FragReuseIndex>>,
1405        index_cache: &LanceCache,
1406    ) -> Result<Arc<Self>> {
1407        let (page_lookup_file, standalone_partition_page_file) =
1408            match store.open_index_file(BTREE_LOOKUP_NAME).await {
1409                Ok(page_lookup_file) => (page_lookup_file, None),
1410                Err(original_err) if is_missing_lookup_error(&original_err) => {
1411                    let files = store.list_files_with_sizes().await?;
1412                    let Some((lookup_file, page_file)) = find_single_partition_files(&files)?
1413                    else {
1414                        return Err(original_err);
1415                    };
1416                    (
1417                        store.open_index_file(lookup_file).await?,
1418                        Some(page_file.to_string()),
1419                    )
1420                }
1421                Err(other_err) => return Err(other_err),
1422            };
1423        let num_rows_in_lookup = page_lookup_file.num_rows();
1424        let serialized_lookup = page_lookup_file
1425            .read_range(0..num_rows_in_lookup, None)
1426            .await?;
1427        let file_schema = page_lookup_file.schema();
1428        let batch_size = file_schema
1429            .metadata
1430            .get(BATCH_SIZE_META_KEY)
1431            .map(|bs| bs.parse().unwrap_or(DEFAULT_BTREE_BATCH_SIZE))
1432            .unwrap_or(DEFAULT_BTREE_BATCH_SIZE);
1433
1434        let range_partitioned = file_schema
1435            .metadata
1436            .get(RANGE_PARTITIONED_META_KEY)
1437            .map(|bs| bs.parse().unwrap_or(DEFAULT_RANGE_PARTITIONED))
1438            .unwrap_or(DEFAULT_RANGE_PARTITIONED);
1439        // For range-partitioned indices, construct the `ranges_to_files` map.
1440        // This converts the list of (partition ID, page count) from metadata into a map
1441        // from a global page range to its corresponding file and starting offset.
1442        let ranges_to_files = if let Some(page_file_name) = standalone_partition_page_file {
1443            let page_numbers = serialized_lookup
1444                .column(3)
1445                .as_any()
1446                .downcast_ref::<UInt32Array>()
1447                .unwrap();
1448            let max_page_number = page_numbers.values().iter().copied().max().unwrap_or(0);
1449            let mut range_map = RangeInclusiveMap::new();
1450            range_map.insert(0..=max_page_number, (page_file_name, 0));
1451            Some(Arc::new(range_map))
1452        } else if range_partitioned {
1453            let part_sizes_str = file_schema
1454            .metadata
1455            .get(PAGE_NUM_PER_RANGE_PARTITION_META_KEY)
1456            .expect("Range-partitioned Btree lookup file must have page-number-per-range-file metadata!");
1457            let part_sizes_vec: Vec<(u64, u32)> = serde_json::from_str(part_sizes_str)?;
1458            let mut offset: u32 = 0;
1459
1460            let range_map = part_sizes_vec
1461                .into_iter()
1462                .map(|(id, size)| {
1463                    let range = offset..=(offset + size - 1);
1464                    let file_with_size = (part_page_data_file_path(id), offset);
1465                    offset += size;
1466                    (range, file_with_size)
1467                })
1468                .collect();
1469
1470            Some(Arc::new(range_map))
1471        } else {
1472            None
1473        };
1474
1475        Ok(Arc::new(Self::try_from_serialized(
1476            serialized_lookup,
1477            store,
1478            index_cache,
1479            batch_size,
1480            ranges_to_files,
1481            frag_reuse_index,
1482        )?))
1483    }
1484
1485    // For legacy reasons a btree index expects the training input to use value/_rowid
1486    fn train_schema(&self) -> Schema {
1487        let value_field = Field::new(VALUE_COLUMN_NAME, self.data_type.clone(), true);
1488        let row_id_field = Field::new(ROW_ID, DataType::UInt64, false);
1489        Schema::new(vec![value_field, row_id_field])
1490    }
1491
1492    /// Create a stream of all the data in the index, in the same format used to train the index
1493    async fn into_data_stream(self) -> Result<SendableRecordBatchStream> {
1494        let lazy_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
1495        let reader = lazy_reader.get().await?;
1496        let new_schema = Arc::new(self.train_schema());
1497        let new_schema_clone = new_schema.clone();
1498        let reader_stream = IndexReaderStream::new(reader, self.batch_size).await;
1499        let batches = reader_stream
1500            .map(|fut| fut.map_err(DataFusionError::from))
1501            .buffered(self.store.io_parallelism())
1502            .map_ok(move |batch| {
1503                RecordBatch::try_new(
1504                    new_schema.clone(),
1505                    vec![batch.column(0).clone(), batch.column(1).clone()],
1506                )
1507                .unwrap()
1508            })
1509            .boxed();
1510        Ok(Box::pin(RecordBatchStreamAdapter::new(
1511            new_schema_clone,
1512            batches,
1513        )))
1514    }
1515
1516    async fn combine_old_new(
1517        self,
1518        new_data: SendableRecordBatchStream,
1519        chunk_size: u64,
1520        old_data_filter: Option<OldIndexDataFilter>,
1521    ) -> Result<SendableRecordBatchStream> {
1522        let value_column_index = new_data.schema().index_of(VALUE_COLUMN_NAME)?;
1523
1524        let new_input = Arc::new(OneShotExec::new(new_data));
1525        let old_stream = self.into_data_stream().await?;
1526        let old_stream = match old_data_filter {
1527            Some(filter) => filter_row_ids(old_stream, filter),
1528            None => old_stream,
1529        };
1530        let old_input = Arc::new(OneShotExec::new(old_stream));
1531        debug_assert_eq!(
1532            old_input.schema().flattened_fields().len(),
1533            new_input.schema().flattened_fields().len()
1534        );
1535
1536        let sort_expr = PhysicalSortExpr {
1537            expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_index)),
1538            options: SortOptions {
1539                descending: false,
1540                nulls_first: true,
1541            },
1542        };
1543        // The UnionExec creates multiple partitions but the SortPreservingMergeExec merges
1544        // them back into a single partition.
1545        let all_data = UnionExec::try_new(vec![old_input, new_input])?;
1546        let ordered = Arc::new(SortPreservingMergeExec::new([sort_expr].into(), all_data));
1547
1548        let unchunked = execute_plan(
1549            ordered,
1550            LanceExecutionOptions {
1551                use_spilling: true,
1552                ..Default::default()
1553            },
1554        )?;
1555        Ok(chunk_concat_stream(unchunked, chunk_size as usize))
1556    }
1557}
1558
1559/// Filter a stream of record batches using the selection semantics encapsulated
1560/// by `old_data_filter`.
1561fn filter_row_ids(
1562    stream: SendableRecordBatchStream,
1563    old_data_filter: OldIndexDataFilter,
1564) -> SendableRecordBatchStream {
1565    let schema = stream.schema();
1566    let filtered = stream.map(move |batch_result| {
1567        let batch = batch_result?;
1568        let row_ids = batch[ROW_ID]
1569            .as_any()
1570            .downcast_ref::<arrow_array::UInt64Array>()
1571            .ok_or_else(|| Error::internal("expected UInt64Array for row_id column"))?;
1572        let mask = old_data_filter.filter_row_ids(row_ids);
1573        Ok(arrow_select::filter::filter_record_batch(&batch, &mask)?)
1574    });
1575    Box::pin(RecordBatchStreamAdapter::new(schema, filtered))
1576}
1577
1578fn wrap_bound(bound: &Bound<ScalarValue>) -> Bound<OrderableScalarValue> {
1579    match bound {
1580        Bound::Unbounded => Bound::Unbounded,
1581        Bound::Included(val) => Bound::Included(OrderableScalarValue(val.clone())),
1582        Bound::Excluded(val) => Bound::Excluded(OrderableScalarValue(val.clone())),
1583    }
1584}
1585
1586fn serialize_with_display<T: Display, S: Serializer>(
1587    value: &Option<T>,
1588    serializer: S,
1589) -> std::result::Result<S::Ok, S::Error> {
1590    if let Some(value) = value {
1591        serializer.collect_str(value)
1592    } else {
1593        serializer.collect_str("N/A")
1594    }
1595}
1596
1597#[derive(Serialize)]
1598struct BTreeStatistics {
1599    #[serde(serialize_with = "serialize_with_display")]
1600    min: Option<OrderableScalarValue>,
1601    #[serde(serialize_with = "serialize_with_display")]
1602    max: Option<OrderableScalarValue>,
1603    num_pages: u32,
1604}
1605
1606#[async_trait]
1607impl Index for BTreeIndex {
1608    fn as_any(&self) -> &dyn Any {
1609        self
1610    }
1611
1612    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
1613        self
1614    }
1615
1616    fn as_vector_index(self: Arc<Self>) -> Result<Arc<dyn crate::vector::VectorIndex>> {
1617        Err(Error::not_supported_source(
1618            "BTreeIndex is not vector index".into(),
1619        ))
1620    }
1621
1622    async fn prewarm(&self) -> Result<()> {
1623        let index_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
1624        let reader = index_reader.get().await?;
1625        let num_pages = reader.num_batches(self.batch_size).await;
1626        let mut pages = stream::iter(0..num_pages)
1627            .map(|page_idx| {
1628                let index_reader = index_reader.clone();
1629                async move {
1630                    let page = self
1631                        .read_page(page_idx, index_reader, &NoOpMetricsCollector)
1632                        .await?;
1633                    Result::Ok((page_idx, page))
1634                }
1635            })
1636            .buffer_unordered(get_num_compute_intensive_cpus());
1637
1638        while let Some((page_idx, page)) = pages.try_next().await? {
1639            let inserted = self
1640                .index_cache
1641                .insert_with_key(
1642                    &BTreePageKey {
1643                        page_number: page_idx,
1644                    },
1645                    Arc::new(page),
1646                )
1647                .await;
1648
1649            if !inserted {
1650                return Err(Error::internal(
1651                    "Failed to prewarm index: cache is no longer available".to_string(),
1652                ));
1653            }
1654        }
1655
1656        Ok(())
1657    }
1658
1659    fn index_type(&self) -> IndexType {
1660        IndexType::BTree
1661    }
1662
1663    fn statistics(&self) -> Result<serde_json::Value> {
1664        let min = self
1665            .page_lookup
1666            .tree
1667            .first_key_value()
1668            .map(|(k, _)| k.clone());
1669        let max = self
1670            .page_lookup
1671            .tree
1672            .last_key_value()
1673            .map(|(k, _)| k.clone());
1674        serde_json::to_value(&BTreeStatistics {
1675            num_pages: self.page_lookup.tree.len() as u32,
1676            min,
1677            max,
1678        })
1679        .map_err(|err| err.into())
1680    }
1681
1682    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
1683        let mut frag_ids = RoaringBitmap::default();
1684
1685        let lazy_reader = LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
1686        let sub_index_reader = lazy_reader.get().await?;
1687        let mut reader_stream = IndexReaderStream::new(sub_index_reader, self.batch_size)
1688            .await
1689            .buffered(self.store.io_parallelism());
1690        while let Some(serialized) = reader_stream.try_next().await? {
1691            let page = FlatIndex::try_new(serialized)?;
1692            frag_ids |= page.calculate_included_frags()?;
1693        }
1694
1695        Ok(frag_ids)
1696    }
1697}
1698
1699#[async_trait]
1700impl ScalarIndex for BTreeIndex {
1701    async fn search(
1702        &self,
1703        query: &dyn AnyQuery,
1704        metrics: &dyn MetricsCollector,
1705    ) -> Result<SearchResult> {
1706        let query = query.as_any().downcast_ref::<SargableQuery>().unwrap();
1707        let mut pages = match query {
1708            SargableQuery::Equals(val) => self
1709                .page_lookup
1710                .pages_eq(&OrderableScalarValue(val.clone())),
1711            SargableQuery::Range(start, end) => self
1712                .page_lookup
1713                .pages_between((wrap_bound(start).as_ref(), wrap_bound(end).as_ref())),
1714            SargableQuery::IsIn(values) => self
1715                .page_lookup
1716                .pages_in(values.iter().map(|val| OrderableScalarValue(val.clone()))),
1717            SargableQuery::FullTextSearch(_) => {
1718                return Err(Error::invalid_input(
1719                    "full text search is not supported for BTree index, build a inverted index for it",
1720                ));
1721            }
1722            SargableQuery::IsNull() => self.page_lookup.pages_null(),
1723            SargableQuery::LikePrefix(prefix) => {
1724                // Convert LikePrefix to a range query: [prefix, next_prefix)
1725                match prefix {
1726                    ScalarValue::Utf8(Some(s)) => {
1727                        let start = Bound::Included(OrderableScalarValue(prefix.clone()));
1728                        let end = match compute_next_prefix(s) {
1729                            Some(next) => {
1730                                Bound::Excluded(OrderableScalarValue(ScalarValue::Utf8(Some(next))))
1731                            }
1732                            None => Bound::Unbounded,
1733                        };
1734                        self.page_lookup
1735                            .pages_between((start.as_ref(), end.as_ref()))
1736                    }
1737                    ScalarValue::LargeUtf8(Some(s)) => {
1738                        let start = Bound::Included(OrderableScalarValue(prefix.clone()));
1739                        let end = match compute_next_prefix(s) {
1740                            Some(next) => Bound::Excluded(OrderableScalarValue(
1741                                ScalarValue::LargeUtf8(Some(next)),
1742                            )),
1743                            None => Bound::Unbounded,
1744                        };
1745                        self.page_lookup
1746                            .pages_between((start.as_ref(), end.as_ref()))
1747                    }
1748                    _ => {
1749                        // Conservative: return all pages for non-string types
1750                        // This is consistent with ZoneMap behavior
1751                        self.page_lookup
1752                            .pages_between((Bound::Unbounded, Bound::Unbounded))
1753                    }
1754                }
1755            }
1756        };
1757
1758        // For non-IsNull queries, also include null pages so that null row IDs
1759        // are tracked in the result. Any comparison with NULL yields NULL, and
1760        // we need this information for correct three-valued logic (e.g. NOT,
1761        // OR). Without this, a query like `NOT(x = 0)` on data where 0 doesn't
1762        // exist would incorrectly include NULL rows.
1763        //
1764        // We add them as Matches::Some (not Matches::All) so that
1765        // FlatIndex::search() evaluates the predicate and correctly marks
1766        // the rows as NULL rather than TRUE.
1767        if !matches!(query, SargableQuery::IsNull()) {
1768            let existing: HashSet<u32> = pages.iter().map(|m| m.page_id()).collect();
1769            for &page_id in self
1770                .page_lookup
1771                .null_pages
1772                .iter()
1773                .chain(self.page_lookup.all_null_pages.iter())
1774            {
1775                if !existing.contains(&page_id) {
1776                    pages.push(Matches::Some(page_id));
1777                }
1778            }
1779        }
1780
1781        let lazy_index_reader =
1782            LazyIndexReader::new(self.store.clone(), self.ranges_to_files.clone());
1783        let page_tasks = pages
1784            .into_iter()
1785            .map(|page_index| {
1786                self.search_page(query, page_index, lazy_index_reader.clone(), metrics)
1787                    .boxed()
1788            })
1789            .collect::<Vec<_>>();
1790        debug!("Searching {} btree pages", page_tasks.len());
1791
1792        // Collect both matching row IDs and null row IDs from all pages
1793        let results: Vec<NullableRowAddrSet> = stream::iter(page_tasks)
1794            // I/O and compute mixed here but important case is index in cache so
1795            // use compute intensive thread count
1796            .buffered(get_num_compute_intensive_cpus())
1797            .try_collect()
1798            .await?;
1799
1800        // Merge matching row IDs
1801        let selection = NullableRowAddrSet::union_all(&results);
1802
1803        Ok(SearchResult::Exact(selection))
1804    }
1805
1806    fn can_remap(&self) -> bool {
1807        true
1808    }
1809
1810    async fn remap(
1811        &self,
1812        mapping: &HashMap<u64, Option<u64>>,
1813        dest_store: &dyn IndexStore,
1814    ) -> Result<CreatedIndex> {
1815        // (part_id, path)
1816        // The part_id is None for a basic index
1817        // For a range-based index we use Some(0), Some(1), ...
1818        //   even if those weren't the original part ids
1819        let part_page_files: Vec<(Option<u32>, &str)> =
1820            if let Some(ranges_to_files) = &self.ranges_to_files {
1821                // Range-based Index: Directly collect references to the file paths.
1822                ranges_to_files
1823                    .iter()
1824                    .enumerate()
1825                    .map(|(part_id, (_, (path, _)))| (Some(part_id as u32), path.as_str()))
1826                    .collect()
1827            } else {
1828                // Basic Index: There is only one source page file.
1829                vec![(None, BTREE_PAGES_NAME)]
1830            };
1831
1832        let mapping = Arc::new(mapping.clone());
1833        let train_schema = Arc::new(self.train_schema());
1834
1835        // TODO: Could potentially parallelize this across parts, unclear it would be worth it
1836        for (part_id, page_file) in part_page_files {
1837            // Retrain on the remapped pages
1838            let sub_index_reader = self.store.open_index_file(page_file).await?;
1839            let mapping = mapping.clone();
1840
1841            let train_schema_clone = train_schema.clone();
1842            let train_schema = train_schema.clone();
1843
1844            let remapped_stream = IndexReaderStream::new(sub_index_reader, self.batch_size)
1845                .await
1846                .buffered(self.store.io_parallelism())
1847                .map_err(DataFusionError::from)
1848                .and_then(move |batch| {
1849                    // Remap the batch and then convert from the serialized schema to the training input schema
1850                    let remapped =
1851                        FlatIndex::remap_batch(batch, &mapping).map_err(DataFusionError::from);
1852                    let with_train_schema = remapped.and_then(|batch| {
1853                        RecordBatch::try_new(train_schema.clone(), batch.columns().to_vec())
1854                            .map_err(DataFusionError::from)
1855                    });
1856                    std::future::ready(with_train_schema)
1857                });
1858
1859            let remapped_stream = Box::pin(RecordBatchStreamAdapter::new(
1860                train_schema_clone,
1861                remapped_stream,
1862            ));
1863
1864            train_btree_index(remapped_stream, dest_store, self.batch_size, None, part_id).await?;
1865        }
1866
1867        if let Some(ranges_to_files) = &self.ranges_to_files {
1868            let num_parts = ranges_to_files.len();
1869            // Merge the lookups if we are a range-based index
1870            let page_files = (0..num_parts)
1871                .map(|part_id| part_page_data_file_path((part_id as u64) << 32))
1872                .collect::<Vec<_>>();
1873            let lookup_files = (0..num_parts)
1874                .map(|part_id| part_lookup_file_path((part_id as u64) << 32))
1875                .collect::<Vec<_>>();
1876            merge_metadata_files(
1877                dest_store,
1878                &page_files,
1879                &lookup_files,
1880                None,
1881                noop_progress(),
1882            )
1883            .await?;
1884        }
1885
1886        Ok(CreatedIndex {
1887            index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
1888                .unwrap(),
1889            index_version: BTREE_INDEX_VERSION,
1890            files: Some(dest_store.list_files_with_sizes().await?),
1891        })
1892    }
1893
1894    async fn update(
1895        &self,
1896        new_data: SendableRecordBatchStream,
1897        dest_store: &dyn IndexStore,
1898        old_data_filter: Option<OldIndexDataFilter>,
1899    ) -> Result<CreatedIndex> {
1900        // Merge the existing index data with the new data and then retrain the index on the merged stream
1901        let merged_data_source = self
1902            .clone()
1903            .combine_old_new(new_data, self.batch_size, old_data_filter)
1904            .await?;
1905        train_btree_index(merged_data_source, dest_store, self.batch_size, None, None).await?;
1906
1907        Ok(CreatedIndex {
1908            index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
1909                .unwrap(),
1910            index_version: BTREE_INDEX_VERSION,
1911            files: Some(dest_store.list_files_with_sizes().await?),
1912        })
1913    }
1914
1915    fn update_criteria(&self) -> UpdateCriteria {
1916        UpdateCriteria::only_new_data(TrainingCriteria::new(TrainingOrdering::Values).with_row_id())
1917    }
1918
1919    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
1920        let params = serde_json::to_value(BTreeParameters {
1921            zone_size: Some(self.batch_size),
1922            range_id: None,
1923        })?;
1924        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::BTree).with_params(&params))
1925    }
1926}
1927
1928struct BatchStats {
1929    min: ScalarValue,
1930    max: ScalarValue,
1931    null_count: u32,
1932}
1933
1934fn analyze_batch(batch: &RecordBatch) -> Result<BatchStats> {
1935    let values = batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?;
1936    if values.is_empty() {
1937        return Err(Error::internal(
1938            "received an empty batch in btree training".to_string(),
1939        ));
1940    }
1941    let min = ScalarValue::try_from_array(&values, 0)
1942        .map_err(|e| Error::internal(format!("failed to get min value from batch: {}", e)))?;
1943    let max = ScalarValue::try_from_array(&values, values.len() - 1)
1944        .map_err(|e| Error::internal(format!("failed to get max value from batch: {}", e)))?;
1945
1946    Ok(BatchStats {
1947        min,
1948        max,
1949        null_count: values.null_count() as u32,
1950    })
1951}
1952
1953/// A trait that must be implemented by anything that wishes to act as a btree subindex
1954#[async_trait]
1955pub trait BTreeSubIndex: Debug + Send + Sync + DeepSizeOf {
1956    /// Trains the subindex on a single batch of data and serializes it to Arrow
1957    async fn train(&self, batch: RecordBatch) -> Result<RecordBatch>;
1958
1959    /// Deserialize a subindex from Arrow
1960    async fn load_subindex(&self, serialized: RecordBatch) -> Result<Arc<dyn ScalarIndex>>;
1961
1962    /// Retrieve the data used to originally train this page
1963    ///
1964    /// In order to perform an update we need to merge the old data in with the new data which
1965    /// means we need to access the new data.  Right now this is convenient for flat indices but
1966    /// we may need to take a different approach if we ever decide to use a sub-index other than
1967    /// flat
1968    async fn retrieve_data(&self, serialized: RecordBatch) -> Result<RecordBatch>;
1969
1970    /// The schema of the subindex when serialized to Arrow
1971    fn schema(&self) -> &Arc<Schema>;
1972
1973    /// Given a serialized page, deserialize it, remap the row ids, and re-serialize it
1974    async fn remap_subindex(
1975        &self,
1976        serialized: RecordBatch,
1977        mapping: &HashMap<u64, Option<u64>>,
1978    ) -> Result<RecordBatch>;
1979}
1980
1981struct EncodedBatch {
1982    stats: BatchStats,
1983    page_number: u32,
1984}
1985
1986async fn train_btree_page(
1987    batch: RecordBatch,
1988    batch_idx: u32,
1989    writer: &mut dyn IndexWriter,
1990    schema: Arc<Schema>,
1991) -> Result<EncodedBatch> {
1992    let stats = analyze_batch(&batch)?;
1993
1994    // Renames from value/_rowid to values/ids
1995    let trained = RecordBatch::try_new(
1996        schema.clone(),
1997        vec![
1998            batch.column_by_name(VALUE_COLUMN_NAME).expect_ok()?.clone(),
1999            batch.column_by_name(ROW_ID).expect_ok()?.clone(),
2000        ],
2001    )?;
2002
2003    writer.write_record_batch(trained).await?;
2004    Ok(EncodedBatch {
2005        stats,
2006        page_number: batch_idx,
2007    })
2008}
2009
2010fn btree_stats_as_batch(stats: Vec<EncodedBatch>, value_type: &DataType) -> Result<RecordBatch> {
2011    let mins = if stats.is_empty() {
2012        new_empty_array(value_type)
2013    } else {
2014        ScalarValue::iter_to_array(stats.iter().map(|stat| stat.stats.min.clone()))?
2015    };
2016    let maxs = if stats.is_empty() {
2017        new_empty_array(value_type)
2018    } else {
2019        ScalarValue::iter_to_array(stats.iter().map(|stat| stat.stats.max.clone()))?
2020    };
2021    let null_counts = UInt32Array::from_iter_values(stats.iter().map(|stat| stat.stats.null_count));
2022    let page_numbers = UInt32Array::from_iter_values(stats.iter().map(|stat| stat.page_number));
2023
2024    let schema = Arc::new(Schema::new(vec![
2025        // min and max can be null if the entire batch is null values
2026        Field::new("min", mins.data_type().clone(), true),
2027        Field::new("max", maxs.data_type().clone(), true),
2028        Field::new("null_count", null_counts.data_type().clone(), false),
2029        Field::new("page_idx", page_numbers.data_type().clone(), false),
2030    ]));
2031
2032    let columns = vec![
2033        mins,
2034        maxs,
2035        Arc::new(null_counts) as Arc<dyn Array>,
2036        Arc::new(page_numbers) as Arc<dyn Array>,
2037    ];
2038
2039    Ok(RecordBatch::try_new(schema, columns)?)
2040}
2041
2042/// Train a btree index from a stream of sorted page-size batches of values and row ids
2043pub async fn train_btree_index(
2044    batches_source: SendableRecordBatchStream,
2045    index_store: &dyn IndexStore,
2046    batch_size: u64,
2047    fragment_ids: Option<Vec<u32>>,
2048    range_id: Option<u32>,
2049) -> Result<()> {
2050    // Create `partition_id` for distributed index building.
2051    // This ID serves as a high-level mask (first 32 bits of a u64) to ensure
2052    // that index partitions generated by different workers do not conflict.
2053    // Lance supports two strategies for distributed training: fragment-based and range-based.
2054    let partition_id = fragment_ids
2055        .as_ref()
2056        // --- Fragment-based Partitioning ---
2057        // Used when training sub-indexes on a fragment-level-split basis. The `partition_id` is
2058        // derived from `fragment_ids` to associate the index pages with their source fragment.
2059        .and_then(|frag_ids| frag_ids.first())
2060        .map(|&first_frag_id| (first_frag_id as u64) << 32)
2061        // --- Range-based Partitioning ---
2062        // Built upon data globally sorted by an external compute engine. The `range_id` creates
2063        // a unique name for the index pages generated by each worker.
2064        .or_else(|| range_id.map(|id| (id as u64) << 32));
2065
2066    let flat_schema = Arc::new(Schema::new(vec![
2067        Field::new(
2068            BTREE_VALUES_COLUMN,
2069            batches_source.schema().field(0).data_type().clone(),
2070            true,
2071        ),
2072        Field::new(BTREE_IDS_COLUMN, DataType::UInt64, false),
2073    ]));
2074
2075    let mut sub_index_file = match partition_id {
2076        None => {
2077            index_store
2078                .new_index_file(BTREE_PAGES_NAME, flat_schema.clone())
2079                .await?
2080        }
2081        Some(partition_id) => {
2082            index_store
2083                .new_index_file(
2084                    part_page_data_file_path(partition_id).as_str(),
2085                    flat_schema.clone(),
2086                )
2087                .await?
2088        }
2089    };
2090
2091    let mut encoded_batches = Vec::new();
2092    let mut batch_idx = 0;
2093
2094    let value_type = batches_source
2095        .schema()
2096        .field_with_name(VALUE_COLUMN_NAME)?
2097        .data_type()
2098        .clone();
2099
2100    let mut batches_source = chunk_concat_stream(batches_source, batch_size as usize);
2101
2102    while let Some(batch) = batches_source.try_next().await? {
2103        encoded_batches.push(
2104            train_btree_page(
2105                batch,
2106                batch_idx,
2107                sub_index_file.as_mut(),
2108                flat_schema.clone(),
2109            )
2110            .await?,
2111        );
2112        batch_idx += 1;
2113    }
2114    sub_index_file.finish().await?;
2115    let record_batch = btree_stats_as_batch(encoded_batches, &value_type)?;
2116    let mut file_schema = record_batch.schema().as_ref().clone();
2117    file_schema
2118        .metadata
2119        .insert(BATCH_SIZE_META_KEY.to_string(), batch_size.to_string());
2120    file_schema.metadata.insert(
2121        RANGE_PARTITIONED_META_KEY.to_string(),
2122        range_id.is_some().to_string(),
2123    );
2124    let mut btree_index_file = match partition_id {
2125        None => {
2126            index_store
2127                .new_index_file(BTREE_LOOKUP_NAME, Arc::new(file_schema))
2128                .await?
2129        }
2130        Some(partition_id) => {
2131            index_store
2132                .new_index_file(
2133                    part_lookup_file_path(partition_id).as_str(),
2134                    Arc::new(file_schema),
2135                )
2136                .await?
2137        }
2138    };
2139    btree_index_file.write_record_batch(record_batch).await?;
2140    btree_index_file.finish().await?;
2141    Ok(())
2142}
2143
2144pub async fn merge_index_files(
2145    object_store: &ObjectStore,
2146    index_dir: &Path,
2147    store: Arc<dyn IndexStore>,
2148    batch_readhead: Option<usize>,
2149    progress: Arc<dyn IndexBuildProgress>,
2150) -> Result<()> {
2151    // List all partition page / lookup files in the index directory
2152    let (part_page_files, part_lookup_files) =
2153        list_page_lookup_files(object_store, index_dir).await?;
2154    merge_metadata_files(
2155        store.as_ref(),
2156        &part_page_files,
2157        &part_lookup_files,
2158        batch_readhead,
2159        progress,
2160    )
2161    .await
2162}
2163
2164/// List and filter files from the index directory
2165/// Returns (page_files, lookup_files)
2166async fn list_page_lookup_files(
2167    object_store: &ObjectStore,
2168    index_dir: &Path,
2169) -> Result<(Vec<String>, Vec<String>)> {
2170    let mut part_page_files = Vec::new();
2171    let mut part_lookup_files = Vec::new();
2172
2173    let mut list_stream = object_store.list(Some(index_dir.clone()));
2174
2175    while let Some(item) = list_stream.next().await {
2176        match item {
2177            Ok(meta) => {
2178                let file_name = meta.location.filename().unwrap_or_default();
2179                // Filter files matching the pattern part_*_page_data.lance
2180                if file_name.starts_with("part_") && file_name.ends_with("_page_data.lance") {
2181                    part_page_files.push(file_name.to_string());
2182                }
2183                // Filter files matching the pattern part_*_page_lookup.lance
2184                if file_name.starts_with("part_") && file_name.ends_with("_page_lookup.lance") {
2185                    part_lookup_files.push(file_name.to_string());
2186                }
2187            }
2188            Err(_) => continue,
2189        }
2190    }
2191
2192    if part_page_files.is_empty() || part_lookup_files.is_empty() {
2193        return Err(Error::internal(format!(
2194            "No partition metadata files found in index directory: {} (page_files: {}, lookup_files: {})",
2195            index_dir,
2196            part_page_files.len(),
2197            part_lookup_files.len()
2198        )));
2199    }
2200
2201    Ok((part_page_files, part_lookup_files))
2202}
2203
2204fn find_single_partition_files(
2205    files: &[lance_table::format::IndexFile],
2206) -> Result<Option<(&str, &str)>> {
2207    let lookup_files = files
2208        .iter()
2209        .filter_map(|file| {
2210            (file.path.starts_with("part_") && file.path.ends_with("_page_lookup.lance"))
2211                .then_some(file.path.as_str())
2212        })
2213        .collect::<Vec<_>>();
2214    let page_files = files
2215        .iter()
2216        .filter_map(|file| {
2217            (file.path.starts_with("part_") && file.path.ends_with("_page_data.lance"))
2218                .then_some(file.path.as_str())
2219        })
2220        .collect::<Vec<_>>();
2221
2222    if lookup_files.len() != 1 || page_files.len() != 1 {
2223        return Ok(None);
2224    }
2225
2226    let lookup_partition_id = extract_partition_id(lookup_files[0])?;
2227    let page_partition_id = extract_partition_id(page_files[0])?;
2228    if lookup_partition_id != page_partition_id {
2229        return Ok(None);
2230    }
2231
2232    Ok(Some((lookup_files[0], page_files[0])))
2233}
2234
2235fn is_missing_lookup_error(err: &Error) -> bool {
2236    matches!(err, Error::NotFound { .. })
2237        || matches!(
2238            err,
2239            Error::IO { source, .. }
2240                if source
2241                    .downcast_ref::<ObjectStoreError>()
2242                    .map(|os_err| matches!(os_err, ObjectStoreError::NotFound { .. }))
2243                    .unwrap_or(false)
2244        )
2245}
2246
2247/// Merge multiple partition page / lookup files into a complete metadata file
2248///
2249/// In a distributed environment, each worker node writes partition page / lookup file for the partitions it processes,
2250/// and this function merges these files into a final metadata file.
2251/// - For fragment-based indices, it performs a full K-way sort-merge of page files to create new global page and lookup files.
2252/// - For range-based indices, it concatenates lookup files, as data is already globally sorted.
2253async fn merge_metadata_files(
2254    store: &dyn IndexStore,
2255    part_page_files: &[String],
2256    part_lookup_files: &[String],
2257    batch_readhead: Option<usize>,
2258    progress: Arc<dyn IndexBuildProgress>,
2259) -> Result<()> {
2260    if part_lookup_files.is_empty() || part_page_files.is_empty() {
2261        return Err(Error::internal(
2262            "No partition files provided for merging".to_string(),
2263        ));
2264    }
2265
2266    // Step 1: Create lookup map for page files by partition ID
2267    if part_lookup_files.len() != part_page_files.len() {
2268        return Err(Error::internal(format!(
2269            "Number of partition lookup files ({}) does not match number of partition page files ({})",
2270            part_lookup_files.len(),
2271            part_page_files.len()
2272        )));
2273    }
2274    let mut page_files_map = HashMap::new();
2275    for page_file in part_page_files {
2276        let partition_id = extract_partition_id(page_file)?;
2277        page_files_map.insert(partition_id, page_file);
2278    }
2279
2280    // Step 2: Validate that all lookup files have corresponding page files
2281    for lookup_file in part_lookup_files {
2282        let partition_id = extract_partition_id(lookup_file)?;
2283        if !page_files_map.contains_key(&partition_id) {
2284            return Err(Error::internal(format!(
2285                "No corresponding page file found for lookup file: {} (partition_id: {})",
2286                lookup_file, partition_id
2287            )));
2288        }
2289    }
2290
2291    // Step 3: Extract shared metadata and generate lookup_schema
2292    let first_lookup_reader = store.open_index_file(&part_lookup_files[0]).await?;
2293    let batch_size = first_lookup_reader
2294        .schema()
2295        .metadata
2296        .get(BATCH_SIZE_META_KEY)
2297        .map(|bs| bs.parse().unwrap_or(DEFAULT_BTREE_BATCH_SIZE))
2298        .unwrap_or(DEFAULT_BTREE_BATCH_SIZE);
2299    let range_partitioned = first_lookup_reader
2300        .schema()
2301        .metadata
2302        .get(RANGE_PARTITIONED_META_KEY)
2303        .map(|bs| bs.parse().unwrap_or(DEFAULT_RANGE_PARTITIONED))
2304        .unwrap_or(DEFAULT_RANGE_PARTITIONED);
2305
2306    // Get the value type from lookup schema (min column)
2307    let value_type = first_lookup_reader
2308        .schema()
2309        .fields
2310        .first()
2311        .unwrap()
2312        .data_type();
2313
2314    let mut metadata = HashMap::new();
2315    metadata.insert(BATCH_SIZE_META_KEY.to_string(), batch_size.to_string());
2316    let lookup_schema = Arc::new(Schema::new(vec![
2317        Field::new("min", value_type.clone(), true),
2318        Field::new("max", value_type.clone(), true),
2319        Field::new("null_count", DataType::UInt32, false),
2320        Field::new("page_idx", DataType::UInt32, false),
2321    ]));
2322
2323    // Step 4: Merge pages and lookups and generate new index files
2324    if range_partitioned {
2325        merge_range_partitioned_lookups(
2326            store,
2327            part_lookup_files,
2328            lookup_schema,
2329            metadata,
2330            batch_size,
2331            batch_readhead,
2332            progress,
2333        )
2334        .await
2335    } else {
2336        merge_pages_and_lookups(
2337            store,
2338            part_page_files,
2339            part_lookup_files,
2340            &page_files_map,
2341            lookup_schema,
2342            metadata,
2343            batch_size,
2344            batch_readhead,
2345            progress,
2346        )
2347        .await
2348    }
2349}
2350
2351/// Merges multiple lookup files from a range-partitioned index into a single, unified lookup file.
2352///
2353/// A range-partitioned B-Tree index creates a separate `page_lookup.lance` file for
2354/// each partition. Each of these files has its own local `page_idx` column, where the indices
2355/// start from 0.
2356///
2357/// This function's primary goal is to combine these separate files into one large
2358/// `page_lookup.lance` file. To do this, it remaps the local `page_idx` from each partition
2359/// file into a contiguous, global `page_idx` space. It processes partition files sequentially,
2360/// calculating an offset based on the number of pages in all previously processed partitions.
2361///
2362/// **The reverse operation occurs when the B-Tree index is loaded**: a global `page_idx` is translated
2363/// back into a `(partition_id, local_page_idx)` tuple. This translation is made possible by the
2364/// metadata stored under the `PAGE_NUM_PER_RANGE_PARTITION_META_KEY`, which this function
2365/// is responsible for writing.
2366///
2367/// # Examples
2368///
2369/// If we have two partition lookup files:
2370/// - `part_0_page_lookup.lance`: Contains 3 pages. Its `page_idx` column is `[0, 1, 2]`.
2371/// - `part_1_page_lookup.lance`: Contains 4 pages. Its `page_idx` column is `[0, 1, 2, 3]`.
2372///
2373/// The merge process works as follows:
2374/// 1. Process `part_0`: The offset is 0. The indices `[0, 1, 2]` are written as is.
2375/// 2. Process `part_1`: The offset is 3 and the local indices `[0, 1, 2, 3]` are remapped
2376///    by adding the offset, resulting in `[3, 4, 5, 6]`.
2377///
2378/// The final, merged `_page_lookup.lance` will have a single `page_idx` column containing
2379/// `[0, 1, 2, 3, 4, 5, 6]`.
2380async fn merge_range_partitioned_lookups(
2381    store: &dyn IndexStore,
2382    part_lookup_files: &[String],
2383    lookup_schema: Arc<Schema>,
2384    mut metadata: HashMap<String, String>,
2385    batch_size: u64,
2386    batch_readhead: Option<usize>,
2387    progress: Arc<dyn IndexBuildProgress>,
2388) -> Result<()> {
2389    let sorted_part_lookup_files = sort_files_by_partition_id(part_lookup_files)?;
2390    let mut lookup_file = store
2391        .new_index_file(BTREE_LOOKUP_NAME, lookup_schema)
2392        .await?;
2393
2394    // stores partition id and the number of pages in that partition
2395    let mut pages_per_file: Vec<(u64, u32)> = Vec::with_capacity(sorted_part_lookup_files.len());
2396    let mut num_pages_written = 0u32;
2397
2398    progress
2399        .stage_start(
2400            "merge_lookups",
2401            Some(sorted_part_lookup_files.len() as u64),
2402            "files",
2403        )
2404        .await?;
2405
2406    for (idx, (part_id, part_lookup_file)) in sorted_part_lookup_files.into_iter().enumerate() {
2407        let lookup_reader = store.open_index_file(&part_lookup_file).await?;
2408        let reader_stream = IndexReaderStream::new(lookup_reader.clone(), batch_size).await;
2409        let mut stream = reader_stream.buffered(batch_readhead.unwrap_or(1)).boxed();
2410        while let Some(batch) = stream.next().await {
2411            let original_batch = batch?;
2412            let modified_batch = add_offset_to_page_idx(&original_batch, num_pages_written)?;
2413            lookup_file.write_record_batch(modified_batch).await?;
2414        }
2415        pages_per_file.push((part_id, lookup_reader.num_rows() as u32));
2416        num_pages_written += lookup_reader.num_rows() as u32;
2417        progress
2418            .stage_progress("merge_lookups", idx as u64 + 1)
2419            .await?;
2420    }
2421
2422    metadata.insert(RANGE_PARTITIONED_META_KEY.to_string(), "true".to_string());
2423    metadata.insert(
2424        PAGE_NUM_PER_RANGE_PARTITION_META_KEY.to_string(),
2425        serde_json::to_string(&pages_per_file)?,
2426    );
2427
2428    lookup_file.finish_with_metadata(metadata).await?;
2429    progress.stage_complete("merge_lookups").await?;
2430
2431    // In this mode, we only clean up lookup files, and page files are untouched.
2432    cleanup_partition_files(store, part_lookup_files, &[]).await;
2433    Ok(())
2434}
2435
2436/// Merges partition files using a K-way sort-merge algorithm.
2437///
2438/// This function assumes its inputs have been pre-validated. It reads from all
2439/// partitioned page files simultaneously, merges them into a single sorted stream,
2440/// writes a new global page file, and generates a corresponding global lookup file.
2441#[allow(clippy::too_many_arguments)]
2442async fn merge_pages_and_lookups(
2443    store: &dyn IndexStore,
2444    part_page_files: &[String],
2445    part_lookup_files: &[String],
2446    page_files_map: &HashMap<u64, &String>,
2447    lookup_schema: Arc<Schema>,
2448    metadata: HashMap<String, String>,
2449    batch_size: u64,
2450    batch_readhead: Option<usize>,
2451    progress: Arc<dyn IndexBuildProgress>,
2452) -> Result<()> {
2453    // Create a new global page file
2454    let partition_id = extract_partition_id(part_lookup_files[0].as_str())?;
2455    let page_file = page_files_map.get(&partition_id).unwrap();
2456    let page_reader = store.open_index_file(page_file).await?;
2457    let page_schema = page_reader.schema().clone();
2458
2459    let arrow_schema = Arc::new(Schema::from(&page_schema));
2460    let mut page_file = store
2461        .new_index_file(BTREE_PAGES_NAME, arrow_schema.clone())
2462        .await?;
2463    progress.stage_start("merge_pages", None, "pages").await?;
2464    let lookup_entries = merge_pages(
2465        part_lookup_files,
2466        page_files_map,
2467        store,
2468        batch_size,
2469        &mut page_file,
2470        arrow_schema.clone(),
2471        batch_readhead,
2472        progress.clone(),
2473    )
2474    .await?;
2475    page_file.finish().await?;
2476    progress.stage_complete("merge_pages").await?;
2477
2478    let lookup_batch = RecordBatch::try_new(
2479        lookup_schema.clone(),
2480        vec![
2481            ScalarValue::iter_to_array(lookup_entries.iter().map(|(min, _, _, _)| min.clone()))?,
2482            ScalarValue::iter_to_array(lookup_entries.iter().map(|(_, max, _, _)| max.clone()))?,
2483            Arc::new(UInt32Array::from_iter_values(
2484                lookup_entries
2485                    .iter()
2486                    .map(|(_, _, null_count, _)| *null_count),
2487            )),
2488            Arc::new(UInt32Array::from_iter_values(
2489                lookup_entries.iter().map(|(_, _, _, page_idx)| *page_idx),
2490            )),
2491        ],
2492    )?;
2493    let mut lookup_file = store
2494        .new_index_file(BTREE_LOOKUP_NAME, lookup_schema)
2495        .await?;
2496    progress
2497        .stage_start("write_lookup_file", Some(1), "files")
2498        .await?;
2499    lookup_file.write_record_batch(lookup_batch).await?;
2500    lookup_file.finish_with_metadata(metadata).await?;
2501    progress.stage_progress("write_lookup_file", 1).await?;
2502    progress.stage_complete("write_lookup_file").await?;
2503
2504    // After successfully writing the merged files, delete all partition files
2505    // Only perform deletion after files are successfully written, ensuring debug information is not lost in case of failure
2506    cleanup_partition_files(store, part_lookup_files, part_page_files).await;
2507
2508    Ok(())
2509}
2510
2511// Adjust local_page_idx_ in each look-up file to create a contiguous global_page_idx
2512fn add_offset_to_page_idx(batch: &RecordBatch, offset: u32) -> Result<RecordBatch> {
2513    let (page_idx_pos, _) = batch.schema().column_with_name("page_idx").ok_or_else(|| {
2514        Error::internal("Column 'page_idx' not found in RecordBatch schema".to_string())
2515    })?;
2516    let page_idx_array = batch
2517        .column(page_idx_pos)
2518        .as_any()
2519        .downcast_ref::<UInt32Array>()
2520        .ok_or_else(|| {
2521            Error::internal("Failed to downcast 'page_idx' column to UInt32Array".to_string())
2522        })?;
2523    let offset_array = UInt32Array::from(vec![offset; page_idx_array.len()]);
2524    let new_page_idx_array_ref = add(page_idx_array, &offset_array)?;
2525    let mut new_columns = batch.columns().to_vec();
2526    new_columns[page_idx_pos] = new_page_idx_array_ref;
2527    let new_batch = RecordBatch::try_new(batch.schema(), new_columns)?;
2528    Ok(new_batch)
2529}
2530
2531/// Merge pages using Datafusion's SortPreservingMergeExec
2532/// which implements a K-way merge algorithm with fixed-size output batches
2533#[allow(clippy::too_many_arguments)]
2534async fn merge_pages(
2535    part_lookup_files: &[String],
2536    page_files_map: &HashMap<u64, &String>,
2537    store: &dyn IndexStore,
2538    batch_size: u64,
2539    page_file: &mut Box<dyn IndexWriter>,
2540    arrow_schema: Arc<Schema>,
2541    batch_readhead: Option<usize>,
2542    progress: Arc<dyn IndexBuildProgress>,
2543) -> Result<Vec<(ScalarValue, ScalarValue, u32, u32)>> {
2544    let mut lookup_entries = Vec::new();
2545    let mut page_idx = 0u32;
2546
2547    debug!(
2548        "Starting SortPreservingMerge with {} partitions",
2549        part_lookup_files.len()
2550    );
2551
2552    let value_field = arrow_schema.field(0).clone().with_name(VALUE_COLUMN_NAME);
2553    let row_id_field = arrow_schema.field(1).clone().with_name(ROW_ID);
2554    let stream_schema = Arc::new(Schema::new(vec![value_field, row_id_field]));
2555
2556    // Create execution plans for each stream
2557    let mut inputs: Vec<Arc<dyn ExecutionPlan>> = Vec::new();
2558    for lookup_file in part_lookup_files {
2559        let partition_id = extract_partition_id(lookup_file)?;
2560        let page_file_name = (*page_files_map.get(&partition_id).ok_or_else(|| {
2561            Error::internal(format!(
2562                "Page file not found for partition ID: {}",
2563                partition_id
2564            ))
2565        })?)
2566        .clone();
2567
2568        let reader = store.open_index_file(&page_file_name).await?;
2569
2570        let reader_stream = IndexReaderStream::new(reader, batch_size).await;
2571
2572        let stream = reader_stream
2573            .map(|fut| fut.map_err(DataFusionError::from))
2574            .buffered(batch_readhead.unwrap_or(1))
2575            .boxed();
2576
2577        let sendable_stream =
2578            Box::pin(RecordBatchStreamAdapter::new(stream_schema.clone(), stream));
2579        inputs.push(Arc::new(OneShotExec::new(sendable_stream)));
2580    }
2581
2582    // Create Union execution plan to combine all partitions
2583    let union_inputs = UnionExec::try_new(inputs)?;
2584
2585    // Create SortPreservingMerge execution plan
2586    let value_column_index = stream_schema.index_of(VALUE_COLUMN_NAME)?;
2587    let sort_expr = PhysicalSortExpr {
2588        expr: Arc::new(Column::new(VALUE_COLUMN_NAME, value_column_index)),
2589        options: SortOptions {
2590            descending: false,
2591            nulls_first: true,
2592        },
2593    };
2594
2595    let merge_exec = Arc::new(SortPreservingMergeExec::new(
2596        [sort_expr].into(),
2597        union_inputs,
2598    ));
2599
2600    let unchunked = execute_plan(
2601        merge_exec,
2602        LanceExecutionOptions {
2603            use_spilling: false,
2604            ..Default::default()
2605        },
2606    )?;
2607
2608    // Use chunk_concat_stream to ensure fixed batch sizes
2609    let mut chunked_stream = chunk_concat_stream(unchunked, batch_size as usize);
2610
2611    // Process chunked stream
2612    while let Some(batch) = chunked_stream.try_next().await? {
2613        let writer_batch = RecordBatch::try_new(
2614            arrow_schema.clone(),
2615            vec![batch.column(0).clone(), batch.column(1).clone()],
2616        )?;
2617
2618        page_file.write_record_batch(writer_batch).await?;
2619
2620        let min_val = ScalarValue::try_from_array(batch.column(0), 0)?;
2621        let max_val = ScalarValue::try_from_array(batch.column(0), batch.num_rows() - 1)?;
2622        let null_count = batch.column(0).null_count() as u32;
2623
2624        lookup_entries.push((min_val, max_val, null_count, page_idx));
2625        page_idx += 1;
2626        progress
2627            .stage_progress("merge_pages", page_idx as u64)
2628            .await?;
2629    }
2630
2631    Ok(lookup_entries)
2632}
2633
2634// Sorts file paths by the partition ID extracted from file name.
2635fn sort_files_by_partition_id(part_files: &[String]) -> Result<Vec<(u64, String)>> {
2636    let mut files_with_ids: Vec<(u64, &String)> = part_files
2637        .iter()
2638        .map(|file| extract_partition_id(file).map(|id| (id, file)))
2639        .collect::<Result<Vec<_>>>()?;
2640
2641    files_with_ids.sort_unstable_by_key(|k| k.0);
2642
2643    let sorted_files = files_with_ids
2644        .into_iter()
2645        .map(|(id, file)| (id, file.clone()))
2646        .collect();
2647
2648    Ok(sorted_files)
2649}
2650
2651/// Extract partition ID from partition file name
2652/// Expected format: "part_{partition_id}_{suffix}.lance"
2653fn extract_partition_id(filename: &str) -> Result<u64> {
2654    if !filename.starts_with("part_") {
2655        return Err(Error::internal(format!(
2656            "Invalid partition file name format: {}",
2657            filename
2658        )));
2659    }
2660
2661    let parts: Vec<&str> = filename.split('_').collect();
2662    if parts.len() < 3 {
2663        return Err(Error::internal(format!(
2664            "Invalid partition file name format: {}",
2665            filename
2666        )));
2667    }
2668
2669    parts[1].parse::<u64>().map_err(|_| {
2670        Error::internal(format!(
2671            "Failed to parse partition ID from filename: {}",
2672            filename
2673        ))
2674    })
2675}
2676
2677/// Clean up partition files after successful merge
2678///
2679/// This function safely deletes partition lookup and page files after a successful merge operation.
2680/// File deletion failures are logged but do not affect the overall success of the merge operation.
2681async fn cleanup_partition_files(
2682    store: &dyn IndexStore,
2683    part_lookup_files: &[String],
2684    part_page_files: &[String],
2685) {
2686    // Clean up partition lookup files
2687    for file_name in part_lookup_files {
2688        cleanup_single_file(
2689            store,
2690            file_name,
2691            "part_",
2692            "_page_lookup.lance",
2693            "partition lookup",
2694        )
2695        .await;
2696    }
2697
2698    // Clean up partition page files
2699    for file_name in part_page_files {
2700        cleanup_single_file(
2701            store,
2702            file_name,
2703            "part_",
2704            "_page_data.lance",
2705            "partition page",
2706        )
2707        .await;
2708    }
2709}
2710
2711/// Helper function to clean up a single partition file
2712///
2713/// Performs safety checks on the filename pattern before attempting deletion.
2714async fn cleanup_single_file(
2715    store: &dyn IndexStore,
2716    file_name: &str,
2717    expected_prefix: &str,
2718    expected_suffix: &str,
2719    file_type: &str,
2720) {
2721    if file_name.starts_with(expected_prefix) && file_name.ends_with(expected_suffix) {
2722        match store.delete_index_file(file_name).await {
2723            Ok(()) => {
2724                debug!("Successfully deleted {} file: {}", file_type, file_name);
2725            }
2726            Err(e) => {
2727                warn!(
2728                    "Failed to delete {} file '{}': {}. \
2729                    This does not affect the merge operation, but may leave \
2730                    partition files that should be cleaned up manually.",
2731                    file_type, file_name, e
2732                );
2733            }
2734        }
2735    } else {
2736        // If the filename doesn't match the expected format, log a warning but don't attempt deletion
2737        warn!(
2738            "Skipping deletion of file '{}' as it does not match the expected \
2739            {} file pattern ({}*{})",
2740            file_name, file_type, expected_prefix, expected_suffix
2741        );
2742    }
2743}
2744
2745pub(crate) fn part_page_data_file_path(partition_id: u64) -> String {
2746    format!("part_{}_{}", partition_id, BTREE_PAGES_NAME)
2747}
2748
2749pub(crate) fn part_lookup_file_path(partition_id: u64) -> String {
2750    format!("part_{}_{}", partition_id, BTREE_LOOKUP_NAME)
2751}
2752
2753/// A stream that reads the original training data back out of the index
2754///
2755/// This is used for updating the index
2756struct IndexReaderStream {
2757    reader: Arc<dyn IndexReader>,
2758    batch_size: u64,
2759    num_batches: u32,
2760    batch_idx: u32,
2761}
2762
2763impl IndexReaderStream {
2764    async fn new(reader: Arc<dyn IndexReader>, batch_size: u64) -> Self {
2765        let num_batches = reader.num_batches(batch_size).await;
2766        Self {
2767            reader,
2768            batch_size,
2769            num_batches,
2770            batch_idx: 0,
2771        }
2772    }
2773}
2774
2775impl Stream for IndexReaderStream {
2776    type Item = BoxFuture<'static, Result<RecordBatch>>;
2777
2778    fn poll_next(
2779        self: std::pin::Pin<&mut Self>,
2780        _cx: &mut std::task::Context<'_>,
2781    ) -> std::task::Poll<Option<Self::Item>> {
2782        let this = self.get_mut();
2783        if this.batch_idx >= this.num_batches {
2784            return std::task::Poll::Ready(None);
2785        }
2786        let batch_num = this.batch_idx;
2787        this.batch_idx += 1;
2788        let reader_copy = this.reader.clone();
2789        let batch_size = this.batch_size;
2790        let read_task = async move {
2791            reader_copy
2792                .read_record_batch(batch_num as u64, batch_size)
2793                .await
2794        }
2795        .boxed();
2796        std::task::Poll::Ready(Some(read_task))
2797    }
2798}
2799
2800/// Parameters for a btree index
2801#[derive(Debug, Serialize, Deserialize)]
2802pub struct BTreeParameters {
2803    /// The number of rows to include in each zone
2804    pub zone_size: Option<u64>,
2805
2806    /// The ordinal ID of a data partition for building a large, distributed BTree index.
2807    ///
2808    /// When building an index from multiple, pre-partitioned data chunks (for example,
2809    /// in a distributed environment), this ID specifies which partition this particular
2810    /// build operation corresponds to.
2811    ///
2812    /// # Data Distribution Requirements
2813    ///
2814    /// If this parameter is `Some(id)`, the caller **must** guarantee that the input data
2815    /// is strictly global sorted. The input data, when considered as a whole across all
2816    /// partitions ordered by `range_id`, must be sorted.
2817    ///
2818    /// Concretely, this means:
2819    ///
2820    /// All values in the data provided for `range_id: N` must be **less than or equal to**
2821    /// all values in the data for `range_id: N+1`.
2822    ///
2823    /// Lance relies on this precondition to ensure the final, merged index is valid and
2824    /// correctly ordered.
2825    ///
2826    /// # `None` Case
2827    ///
2828    /// If `range_id` is `None`, a single, monolithic index is built over the provided dataset.
2829    pub range_id: Option<u32>,
2830}
2831
2832struct BTreeTrainingRequest {
2833    parameters: BTreeParameters,
2834    criteria: TrainingCriteria,
2835}
2836
2837impl BTreeTrainingRequest {
2838    pub fn new(parameters: BTreeParameters) -> Self {
2839        Self {
2840            parameters,
2841            // BTree indexes need data sorted by the value column
2842            criteria: TrainingCriteria::new(TrainingOrdering::Values).with_row_id(),
2843        }
2844    }
2845}
2846
2847impl TrainingRequest for BTreeTrainingRequest {
2848    fn as_any(&self) -> &dyn std::any::Any {
2849        self
2850    }
2851
2852    fn criteria(&self) -> &TrainingCriteria {
2853        &self.criteria
2854    }
2855}
2856
2857#[derive(Debug, Default)]
2858pub struct BTreeIndexPlugin;
2859
2860#[async_trait]
2861impl ScalarIndexPlugin for BTreeIndexPlugin {
2862    fn name(&self) -> &str {
2863        "BTree"
2864    }
2865
2866    fn new_training_request(
2867        &self,
2868        params: &str,
2869        field: &Field,
2870    ) -> Result<Box<dyn TrainingRequest>> {
2871        if field.data_type().is_nested() {
2872            return Err(Error::invalid_input_source(
2873                "A btree index can only be created on a non-nested field.".into(),
2874            ));
2875        }
2876
2877        let params = serde_json::from_str::<BTreeParameters>(params)?;
2878        Ok(Box::new(BTreeTrainingRequest::new(params)))
2879    }
2880
2881    fn provides_exact_answer(&self) -> bool {
2882        true
2883    }
2884
2885    fn version(&self) -> u32 {
2886        BTREE_INDEX_VERSION
2887    }
2888
2889    fn new_query_parser(
2890        &self,
2891        index_name: String,
2892        _index_details: &prost_types::Any,
2893    ) -> Option<Box<dyn ScalarQueryParser>> {
2894        Some(Box::new(SargableQueryParser::new(
2895            index_name,
2896            self.name().to_string(),
2897            false,
2898        )))
2899    }
2900
2901    async fn train_index(
2902        &self,
2903        data: SendableRecordBatchStream,
2904        index_store: &dyn IndexStore,
2905        request: Box<dyn TrainingRequest>,
2906        fragment_ids: Option<Vec<u32>>,
2907        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
2908    ) -> Result<CreatedIndex> {
2909        let request = request
2910            .as_any()
2911            .downcast_ref::<BTreeTrainingRequest>()
2912            .unwrap();
2913        train_btree_index(
2914            data,
2915            index_store,
2916            request
2917                .parameters
2918                .zone_size
2919                .unwrap_or(DEFAULT_BTREE_BATCH_SIZE),
2920            fragment_ids,
2921            request.parameters.range_id,
2922        )
2923        .await?;
2924        Ok(CreatedIndex {
2925            index_details: prost_types::Any::from_msg(&pbold::BTreeIndexDetails::default())
2926                .unwrap(),
2927            index_version: BTREE_INDEX_VERSION,
2928            files: Some(index_store.list_files_with_sizes().await?),
2929        })
2930    }
2931
2932    async fn load_index(
2933        &self,
2934        index_store: Arc<dyn IndexStore>,
2935        _index_details: &prost_types::Any,
2936        frag_reuse_index: Option<Arc<FragReuseIndex>>,
2937        cache: &LanceCache,
2938    ) -> Result<Arc<dyn ScalarIndex>> {
2939        Ok(BTreeIndex::load(index_store, frag_reuse_index, cache).await? as Arc<dyn ScalarIndex>)
2940    }
2941
2942    async fn get_from_cache(
2943        &self,
2944        index_store: Arc<dyn IndexStore>,
2945        frag_reuse_index: Option<Arc<FragReuseIndex>>,
2946        cache: &LanceCache,
2947    ) -> Result<Option<Arc<dyn ScalarIndex>>> {
2948        let Some(state) = cache.get_with_key(&BTreeIndexStateKey).await else {
2949            return Ok(None);
2950        };
2951        Ok(Some(state.reconstruct(
2952            index_store,
2953            cache,
2954            frag_reuse_index,
2955        )?))
2956    }
2957
2958    async fn put_in_cache(&self, cache: &LanceCache, index: Arc<dyn ScalarIndex>) -> Result<()> {
2959        let btree = index.as_any().downcast_ref::<BTreeIndex>().ok_or_else(|| {
2960            Error::internal("BTreeIndexPlugin::put_in_cache called with a non-BTree index")
2961        })?;
2962        let state = BTreeIndexState {
2963            lookup_batch: btree.lookup_batch.clone(),
2964            batch_size: btree.batch_size,
2965            ranges_to_files: btree.ranges_to_files.clone(),
2966        };
2967        cache
2968            .insert_with_key(&BTreeIndexStateKey, Arc::new(state))
2969            .await;
2970        Ok(())
2971    }
2972}
2973
2974#[cfg(test)]
2975mod tests {
2976    use std::sync::atomic::Ordering;
2977    use std::{collections::HashMap, sync::Arc};
2978
2979    use arrow::datatypes::{Float32Type, Float64Type, Int32Type, UInt64Type};
2980    use arrow_array::{FixedSizeListArray, record_batch};
2981    use datafusion::{
2982        execution::{SendableRecordBatchStream, TaskContext},
2983        physical_plan::{ExecutionPlan, sorts::sort::SortExec, stream::RecordBatchStreamAdapter},
2984    };
2985    use datafusion_common::{DataFusionError, ScalarValue};
2986    use datafusion_physical_expr::{PhysicalSortExpr, expressions::col};
2987    use deepsize::DeepSizeOf;
2988    use futures::TryStreamExt;
2989    use futures::stream;
2990    use lance_core::utils::mask::RowSetOps;
2991    use lance_core::utils::tempfile::TempObjDir;
2992    use lance_core::{cache::LanceCache, utils::mask::RowAddrTreeMap};
2993    use lance_datafusion::{chunker::break_stream, datagen::DatafusionDatagenExt};
2994    use lance_datagen::{ArrayGeneratorExt, BatchCount, RowCount, array, gen_batch};
2995    use lance_io::object_store::ObjectStore;
2996    use object_store::path::Path;
2997
2998    use crate::metrics::LocalMetricsCollector;
2999    use crate::progress::{IndexBuildProgress, noop_progress};
3000    use crate::{
3001        metrics::NoOpMetricsCollector,
3002        scalar::{
3003            IndexStore, OldIndexDataFilter, SargableQuery, ScalarIndex, SearchResult,
3004            btree::{BTREE_PAGES_NAME, BTreeIndex},
3005            lance_format::LanceIndexStore,
3006        },
3007    };
3008
3009    use super::{
3010        BTreeIndexPlugin, BTreeIndexState, BTreePageKey, DEFAULT_BTREE_BATCH_SIZE,
3011        OrderableScalarValue, part_lookup_file_path, part_page_data_file_path, train_btree_index,
3012    };
3013    use crate::scalar::registry::ScalarIndexPlugin;
3014    use arrow_array::RecordBatch;
3015    use lance_core::cache::{CacheCodecImpl, CacheKey};
3016    use rangemap::RangeInclusiveMap;
3017
3018    lance_testing::define_stage_event_progress!(
3019        RecordingProgress,
3020        IndexBuildProgress,
3021        lance_core::Result<()>
3022    );
3023    #[test]
3024    fn test_scalar_value_size() {
3025        let size_of_i32 = OrderableScalarValue(ScalarValue::Int32(Some(0))).deep_size_of();
3026        let size_of_many_i32 = OrderableScalarValue(ScalarValue::FixedSizeList(Arc::new(
3027            FixedSizeListArray::from_iter_primitive::<Int32Type, _, _>(
3028                vec![Some(vec![Some(0); 128])],
3029                128,
3030            ),
3031        )))
3032        .deep_size_of();
3033
3034        // deep_size_of should account for the rust type overhead
3035        assert!(size_of_i32 > 4);
3036        assert!(size_of_many_i32 > 128 * 4);
3037    }
3038
3039    #[tokio::test]
3040    async fn test_null_ids() {
3041        let tmpdir = TempObjDir::default();
3042        let test_store = Arc::new(LanceIndexStore::new(
3043            Arc::new(ObjectStore::local()),
3044            tmpdir.clone(),
3045            Arc::new(LanceCache::no_cache()),
3046        ));
3047
3048        // Generate 50,000 rows of random data with 80% nulls
3049        let stream = gen_batch()
3050            .col(
3051                "value",
3052                array::rand::<Float32Type>().with_nulls(&[true, false, false, false, false]),
3053            )
3054            .col("_rowid", array::step::<UInt64Type>())
3055            .into_df_stream(RowCount::from(5000), BatchCount::from(10));
3056
3057        train_btree_index(stream, test_store.as_ref(), 5000, None, None)
3058            .await
3059            .unwrap();
3060
3061        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
3062            .await
3063            .unwrap();
3064
3065        assert_eq!(index.page_lookup.null_pages.len(), 10);
3066
3067        let remap_dir = TempObjDir::default();
3068        let remap_store = Arc::new(LanceIndexStore::new(
3069            Arc::new(ObjectStore::local()),
3070            remap_dir.clone(),
3071            Arc::new(LanceCache::no_cache()),
3072        ));
3073
3074        // Remap with a no-op mapping.  The remapped index should be identical to the original
3075        index
3076            .remap(&HashMap::default(), remap_store.as_ref())
3077            .await
3078            .unwrap();
3079
3080        let remap_index = BTreeIndex::load(remap_store.clone(), None, &LanceCache::no_cache())
3081            .await
3082            .unwrap();
3083
3084        assert_eq!(remap_index.page_lookup, index.page_lookup);
3085
3086        let original_pages = test_store.open_index_file(BTREE_PAGES_NAME).await.unwrap();
3087        let remapped_pages = remap_store.open_index_file(BTREE_PAGES_NAME).await.unwrap();
3088
3089        assert_eq!(original_pages.num_rows(), remapped_pages.num_rows());
3090
3091        let original_data = original_pages
3092            .read_record_batch(0, original_pages.num_rows() as u64)
3093            .await
3094            .unwrap();
3095        let remapped_data = remapped_pages
3096            .read_record_batch(0, remapped_pages.num_rows() as u64)
3097            .await
3098            .unwrap();
3099
3100        assert_eq!(original_data, remapped_data);
3101    }
3102
3103    #[tokio::test]
3104    async fn test_nan_ordering() {
3105        let tmpdir = TempObjDir::default();
3106        let test_store = Arc::new(LanceIndexStore::new(
3107            Arc::new(ObjectStore::local()),
3108            tmpdir.clone(),
3109            Arc::new(LanceCache::no_cache()),
3110        ));
3111
3112        let values = vec![
3113            0.0,
3114            1.0,
3115            2.0,
3116            3.0,
3117            f64::NAN,
3118            f64::NEG_INFINITY,
3119            f64::INFINITY,
3120        ];
3121
3122        // This is a bit overkill but we've had bugs in the past where DF's sort
3123        // didn't agree with Arrow's sort so we do an end-to-end test here
3124        // and use DF to sort the data like we would in a real dataset.
3125        let data = gen_batch()
3126            .col("value", array::cycle::<Float64Type>(values.clone()))
3127            .col("_rowid", array::step::<UInt64Type>())
3128            .into_df_exec(RowCount::from(10), BatchCount::from(100));
3129        let schema = data.schema();
3130        let sort_expr = PhysicalSortExpr::new_default(col("value", schema.as_ref()).unwrap());
3131        let plan = Arc::new(SortExec::new([sort_expr].into(), data));
3132        let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
3133        let stream = break_stream(stream, 64);
3134        let stream = stream.map_err(DataFusionError::from);
3135        let stream =
3136            Box::pin(RecordBatchStreamAdapter::new(schema, stream)) as SendableRecordBatchStream;
3137
3138        train_btree_index(stream, test_store.as_ref(), 64, None, None)
3139            .await
3140            .unwrap();
3141
3142        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3143            .await
3144            .unwrap();
3145
3146        for (idx, value) in values.into_iter().enumerate() {
3147            let query = SargableQuery::Equals(ScalarValue::Float64(Some(value)));
3148            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3149            assert_eq!(
3150                result,
3151                SearchResult::exact(RowAddrTreeMap::from_iter(((idx as u64)..1000).step_by(7)))
3152            );
3153        }
3154    }
3155
3156    #[tokio::test]
3157    async fn test_page_cache() {
3158        let tmpdir = TempObjDir::default();
3159        let test_store = Arc::new(LanceIndexStore::new(
3160            Arc::new(ObjectStore::local()),
3161            tmpdir.clone(),
3162            Arc::new(LanceCache::no_cache()),
3163        ));
3164
3165        let data = gen_batch()
3166            .col("value", array::step::<Float32Type>())
3167            .col("_rowid", array::step::<UInt64Type>())
3168            .into_df_exec(RowCount::from(1000), BatchCount::from(10));
3169        let schema = data.schema();
3170        let sort_expr = PhysicalSortExpr::new_default(col("value", schema.as_ref()).unwrap());
3171        let plan = Arc::new(SortExec::new([sort_expr].into(), data));
3172        let stream = plan.execute(0, Arc::new(TaskContext::default())).unwrap();
3173        let stream = break_stream(stream, 64);
3174        let stream = stream.map_err(DataFusionError::from);
3175        let stream =
3176            Box::pin(RecordBatchStreamAdapter::new(schema, stream)) as SendableRecordBatchStream;
3177
3178        train_btree_index(stream, test_store.as_ref(), 64, None, None)
3179            .await
3180            .unwrap();
3181
3182        let cache = Arc::new(LanceCache::with_capacity(100 * 1024 * 1024));
3183        let index = BTreeIndex::load(test_store, None, cache.as_ref())
3184            .await
3185            .unwrap();
3186
3187        let query = SargableQuery::Equals(ScalarValue::Float32(Some(0.0)));
3188        let metrics = LocalMetricsCollector::default();
3189        let query1 = index.search(&query, &metrics);
3190        let query2 = index.search(&query, &metrics);
3191        tokio::join!(query1, query2).0.unwrap();
3192        assert_eq!(metrics.parts_loaded.load(Ordering::Relaxed), 1);
3193    }
3194
3195    #[tokio::test]
3196    async fn test_like_prefix_search() {
3197        use arrow::datatypes::DataType;
3198        use arrow_array::StringArray;
3199
3200        let tmpdir = TempObjDir::default();
3201        let test_store = Arc::new(LanceIndexStore::new(
3202            Arc::new(ObjectStore::local()),
3203            tmpdir.clone(),
3204            Arc::new(LanceCache::no_cache()),
3205        ));
3206
3207        // Create string data with various prefixes
3208        let values = vec![
3209            "apple",
3210            "app",
3211            "application",
3212            "banana",
3213            "band",
3214            "test_ns$table1",
3215            "test_ns$table2",
3216            "test_ns2$table1",
3217            "test",
3218            "testing",
3219        ];
3220        let row_ids: Vec<u64> = (0..values.len() as u64).collect();
3221
3222        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
3223            arrow::datatypes::Field::new("value", DataType::Utf8, false),
3224            arrow::datatypes::Field::new("_rowid", DataType::UInt64, false),
3225        ]));
3226
3227        let batch = arrow::record_batch::RecordBatch::try_new(
3228            schema.clone(),
3229            vec![
3230                Arc::new(StringArray::from(values.clone())),
3231                Arc::new(arrow_array::UInt64Array::from(row_ids)),
3232            ],
3233        )
3234        .unwrap();
3235
3236        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
3237            schema,
3238            stream::once(async { Ok(batch) }),
3239        ));
3240
3241        train_btree_index(stream, test_store.as_ref(), 100, None, None)
3242            .await
3243            .unwrap();
3244
3245        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3246            .await
3247            .unwrap();
3248
3249        // Test LikePrefix for "app" - should match "apple", "app", "application" (row ids 0, 1, 2)
3250        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("app".to_string())));
3251        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3252
3253        match &result {
3254            SearchResult::Exact(row_ids) => {
3255                let ids: Vec<u64> = row_ids
3256                    .true_rows()
3257                    .row_addrs()
3258                    .unwrap()
3259                    .map(u64::from)
3260                    .collect();
3261                assert!(ids.contains(&0), "Should contain row 0 (apple)");
3262                assert!(ids.contains(&1), "Should contain row 1 (app)");
3263                assert!(ids.contains(&2), "Should contain row 2 (application)");
3264                assert!(!ids.contains(&3), "Should not contain row 3 (banana)");
3265            }
3266            _ => panic!("Expected Exact result"),
3267        }
3268
3269        // Test LikePrefix for "test_ns$" - should match "test_ns$table1", "test_ns$table2" (row ids 5, 6)
3270        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test_ns$".to_string())));
3271        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3272
3273        match &result {
3274            SearchResult::Exact(row_ids) => {
3275                let ids: Vec<u64> = row_ids
3276                    .true_rows()
3277                    .row_addrs()
3278                    .unwrap()
3279                    .map(u64::from)
3280                    .collect();
3281                assert!(ids.contains(&5), "Should contain row 5 (test_ns$table1)");
3282                assert!(ids.contains(&6), "Should contain row 6 (test_ns$table2)");
3283                assert!(
3284                    !ids.contains(&7),
3285                    "Should not contain row 7 (test_ns2$table1)"
3286                );
3287            }
3288            _ => panic!("Expected Exact result"),
3289        }
3290
3291        // Test LikePrefix for "test" - should match "test", "testing", "test_ns$table1", "test_ns$table2", "test_ns2$table1"
3292        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test".to_string())));
3293        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3294
3295        match &result {
3296            SearchResult::Exact(row_ids) => {
3297                let ids: Vec<u64> = row_ids
3298                    .true_rows()
3299                    .row_addrs()
3300                    .unwrap()
3301                    .map(u64::from)
3302                    .collect();
3303                assert!(
3304                    ids.contains(&5),
3305                    "Should contain row 5 (test_ns$table1): {:?}",
3306                    ids
3307                );
3308                assert!(
3309                    ids.contains(&6),
3310                    "Should contain row 6 (test_ns$table2): {:?}",
3311                    ids
3312                );
3313                assert!(
3314                    ids.contains(&7),
3315                    "Should contain row 7 (test_ns2$table1): {:?}",
3316                    ids
3317                );
3318                assert!(ids.contains(&8), "Should contain row 8 (test): {:?}", ids);
3319                assert!(
3320                    ids.contains(&9),
3321                    "Should contain row 9 (testing): {:?}",
3322                    ids
3323                );
3324            }
3325            _ => panic!("Expected Exact result"),
3326        }
3327    }
3328
3329    #[tokio::test]
3330    async fn test_like_prefix_search_large_utf8() {
3331        use arrow::datatypes::DataType;
3332        use arrow_array::LargeStringArray;
3333
3334        let tmpdir = TempObjDir::default();
3335        let test_store = Arc::new(LanceIndexStore::new(
3336            Arc::new(ObjectStore::local()),
3337            tmpdir.clone(),
3338            Arc::new(LanceCache::no_cache()),
3339        ));
3340
3341        let values = vec!["apple", "app", "application", "banana"];
3342        let row_ids: Vec<u64> = (0..values.len() as u64).collect();
3343
3344        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
3345            arrow::datatypes::Field::new("value", DataType::LargeUtf8, false),
3346            arrow::datatypes::Field::new("_rowid", DataType::UInt64, false),
3347        ]));
3348
3349        let batch = arrow::record_batch::RecordBatch::try_new(
3350            schema.clone(),
3351            vec![
3352                Arc::new(LargeStringArray::from(values)),
3353                Arc::new(arrow_array::UInt64Array::from(row_ids)),
3354            ],
3355        )
3356        .unwrap();
3357
3358        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
3359            schema,
3360            stream::once(async { Ok(batch) }),
3361        ));
3362
3363        train_btree_index(stream, test_store.as_ref(), 100, None, None)
3364            .await
3365            .unwrap();
3366
3367        let index = BTreeIndex::load(test_store, None, &LanceCache::no_cache())
3368            .await
3369            .unwrap();
3370
3371        // Test LikePrefix with LargeUtf8
3372        let query = SargableQuery::LikePrefix(ScalarValue::LargeUtf8(Some("app".to_string())));
3373        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3374
3375        match &result {
3376            SearchResult::Exact(row_ids) => {
3377                let ids: Vec<u64> = row_ids
3378                    .true_rows()
3379                    .row_addrs()
3380                    .unwrap()
3381                    .map(u64::from)
3382                    .collect();
3383                assert!(ids.contains(&0), "Should contain row 0 (apple)");
3384                assert!(ids.contains(&1), "Should contain row 1 (app)");
3385                assert!(ids.contains(&2), "Should contain row 2 (application)");
3386                assert!(!ids.contains(&3), "Should not contain row 3 (banana)");
3387            }
3388            _ => panic!("Expected Exact result"),
3389        }
3390    }
3391
3392    #[tokio::test]
3393    async fn test_fragment_btree_index_consistency() {
3394        // Setup stores for both indexes
3395        let full_tmpdir = TempObjDir::default();
3396        let full_store = Arc::new(LanceIndexStore::new(
3397            Arc::new(ObjectStore::local()),
3398            full_tmpdir.clone(),
3399            Arc::new(LanceCache::no_cache()),
3400        ));
3401
3402        let fragment_tmpdir = TempObjDir::default();
3403        let fragment_store = Arc::new(LanceIndexStore::new(
3404            Arc::new(ObjectStore::local()),
3405            fragment_tmpdir.clone(),
3406            Arc::new(LanceCache::no_cache()),
3407        ));
3408
3409        // Method 1: Build complete index directly using the same data
3410        // Create deterministic data for comparison - use 2 * DEFAULT_BTREE_BATCH_SIZE for testing
3411        let total_count = 2 * DEFAULT_BTREE_BATCH_SIZE;
3412        let full_data_gen = gen_batch()
3413            .col("value", array::step::<Int32Type>())
3414            .col("_rowid", array::step::<UInt64Type>())
3415            .into_df_stream(RowCount::from(total_count / 2), BatchCount::from(2));
3416        let full_data_source = Box::pin(RecordBatchStreamAdapter::new(
3417            full_data_gen.schema(),
3418            full_data_gen,
3419        ));
3420
3421        train_btree_index(
3422            full_data_source,
3423            full_store.as_ref(),
3424            DEFAULT_BTREE_BATCH_SIZE,
3425            None,
3426            None,
3427        )
3428        .await
3429        .unwrap();
3430
3431        // Method 2: Build fragment-based index using the same data split into fragments
3432        // Create fragment 1 index - first half of the data (0 to DEFAULT_BTREE_BATCH_SIZE-1)
3433        let half_count = DEFAULT_BTREE_BATCH_SIZE;
3434        let fragment1_gen = gen_batch()
3435            .col("value", array::step::<Int32Type>())
3436            .col("_rowid", array::step::<UInt64Type>())
3437            .into_df_stream(RowCount::from(half_count), BatchCount::from(1));
3438        let fragment1_data_source = Box::pin(RecordBatchStreamAdapter::new(
3439            fragment1_gen.schema(),
3440            fragment1_gen,
3441        ));
3442
3443        train_btree_index(
3444            fragment1_data_source,
3445            fragment_store.as_ref(),
3446            DEFAULT_BTREE_BATCH_SIZE,
3447            Some(vec![1]), // fragment_id = 1
3448            None,
3449        )
3450        .await
3451        .unwrap();
3452
3453        // Create fragment 2 index - second half of the data (DEFAULT_BTREE_BATCH_SIZE to 2*DEFAULT_BTREE_BATCH_SIZE-1)
3454        let start_val = DEFAULT_BTREE_BATCH_SIZE as i32;
3455        let end_val = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3456        let values_second_half: Vec<i32> = (start_val..end_val).collect();
3457        let row_ids_second_half: Vec<u64> = (start_val as u64..end_val as u64).collect();
3458        let fragment2_gen = gen_batch()
3459            .col("value", array::cycle::<Int32Type>(values_second_half))
3460            .col("_rowid", array::cycle::<UInt64Type>(row_ids_second_half))
3461            .into_df_stream(RowCount::from(half_count), BatchCount::from(1));
3462        let fragment2_data_source = Box::pin(RecordBatchStreamAdapter::new(
3463            fragment2_gen.schema(),
3464            fragment2_gen,
3465        ));
3466
3467        train_btree_index(
3468            fragment2_data_source,
3469            fragment_store.as_ref(),
3470            DEFAULT_BTREE_BATCH_SIZE,
3471            Some(vec![2]), // fragment_id = 2
3472            None,
3473        )
3474        .await
3475        .unwrap();
3476
3477        // Merge the fragment files
3478        let part_page_files = vec![
3479            part_page_data_file_path(1 << 32),
3480            part_page_data_file_path(2 << 32),
3481        ];
3482
3483        let part_lookup_files = vec![
3484            part_lookup_file_path(1 << 32),
3485            part_lookup_file_path(2 << 32),
3486        ];
3487
3488        let progress = Arc::new(RecordingProgress::default());
3489        super::merge_metadata_files(
3490            fragment_store.as_ref(),
3491            &part_page_files,
3492            &part_lookup_files,
3493            Option::from(1usize),
3494            progress.clone(),
3495        )
3496        .await
3497        .unwrap();
3498
3499        let tags = progress
3500            .recorded_events()
3501            .iter()
3502            .map(|(kind, stage, _)| format!("{kind}:{stage}"))
3503            .collect::<Vec<_>>();
3504        let merge_start = tags
3505            .iter()
3506            .position(|e| e == "start:merge_pages")
3507            .expect("missing merge_pages start");
3508        let merge_complete = tags
3509            .iter()
3510            .position(|e| e == "complete:merge_pages")
3511            .expect("missing merge_pages complete");
3512        let lookup_start = tags
3513            .iter()
3514            .position(|e| e == "start:write_lookup_file")
3515            .expect("missing write_lookup_file start");
3516        let lookup_complete = tags
3517            .iter()
3518            .position(|e| e == "complete:write_lookup_file")
3519            .expect("missing write_lookup_file complete");
3520        assert!(merge_start < merge_complete);
3521        assert!(merge_complete < lookup_start);
3522        assert!(lookup_start < lookup_complete);
3523        assert!(
3524            tags.iter().any(|e| e == "progress:merge_pages"),
3525            "expected merge_pages progress callbacks"
3526        );
3527        assert!(
3528            tags.iter().any(|e| e == "progress:write_lookup_file"),
3529            "expected write_lookup_file progress callbacks"
3530        );
3531
3532        // Load both indexes
3533        let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache())
3534            .await
3535            .unwrap();
3536
3537        let merged_index = BTreeIndex::load(fragment_store.clone(), None, &LanceCache::no_cache())
3538            .await
3539            .unwrap();
3540
3541        // Test queries one by one to identify the exact problem
3542
3543        // Test 1: Query for value 0 (should be in first page)
3544        let query_0 = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
3545        let full_result_0 = full_index
3546            .search(&query_0, &NoOpMetricsCollector)
3547            .await
3548            .unwrap();
3549        let merged_result_0 = merged_index
3550            .search(&query_0, &NoOpMetricsCollector)
3551            .await
3552            .unwrap();
3553        assert_eq!(full_result_0, merged_result_0, "Query for value 0 failed");
3554
3555        // Test 2: Query for value in middle of first batch (should be in first page)
3556        let mid_first_batch = (DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
3557        let query_mid_first = SargableQuery::Equals(ScalarValue::Int32(Some(mid_first_batch)));
3558        let full_result_mid_first = full_index
3559            .search(&query_mid_first, &NoOpMetricsCollector)
3560            .await
3561            .unwrap();
3562        let merged_result_mid_first = merged_index
3563            .search(&query_mid_first, &NoOpMetricsCollector)
3564            .await
3565            .unwrap();
3566        assert_eq!(
3567            full_result_mid_first, merged_result_mid_first,
3568            "Query for value {} failed",
3569            mid_first_batch
3570        );
3571
3572        // Test 3: Query for first value in second batch (should be in second page)
3573        let first_second_batch = DEFAULT_BTREE_BATCH_SIZE as i32;
3574        let query_first_second =
3575            SargableQuery::Equals(ScalarValue::Int32(Some(first_second_batch)));
3576        let full_result_first_second = full_index
3577            .search(&query_first_second, &NoOpMetricsCollector)
3578            .await
3579            .unwrap();
3580        let merged_result_first_second = merged_index
3581            .search(&query_first_second, &NoOpMetricsCollector)
3582            .await
3583            .unwrap();
3584        assert_eq!(
3585            full_result_first_second, merged_result_first_second,
3586            "Query for value {} failed",
3587            first_second_batch
3588        );
3589
3590        // Test 4: Query for value in middle of second batch (should be in second page)
3591        let mid_second_batch = (DEFAULT_BTREE_BATCH_SIZE + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
3592        let query_mid_second = SargableQuery::Equals(ScalarValue::Int32(Some(mid_second_batch)));
3593
3594        let full_result_mid_second = full_index
3595            .search(&query_mid_second, &NoOpMetricsCollector)
3596            .await
3597            .unwrap();
3598        let merged_result_mid_second = merged_index
3599            .search(&query_mid_second, &NoOpMetricsCollector)
3600            .await
3601            .unwrap();
3602        assert_eq!(
3603            full_result_mid_second, merged_result_mid_second,
3604            "Query for value {} failed",
3605            mid_second_batch
3606        );
3607    }
3608
3609    #[tokio::test]
3610    async fn test_fragment_btree_index_boundary_queries() {
3611        // Setup stores for both indexes
3612        let full_tmpdir = TempObjDir::default();
3613        let full_store = Arc::new(LanceIndexStore::new(
3614            Arc::new(ObjectStore::local()),
3615            full_tmpdir.clone(),
3616            Arc::new(LanceCache::no_cache()),
3617        ));
3618
3619        let fragment_tmpdir = TempObjDir::default();
3620        let fragment_store = Arc::new(LanceIndexStore::new(
3621            Arc::new(ObjectStore::local()),
3622            fragment_tmpdir.clone(),
3623            Arc::new(LanceCache::no_cache()),
3624        ));
3625
3626        // Use 3 * DEFAULT_BTREE_BATCH_SIZE for more comprehensive boundary testing
3627        let total_count = 3 * DEFAULT_BTREE_BATCH_SIZE;
3628
3629        // Method 1: Build complete index directly
3630        let full_data_gen = gen_batch()
3631            .col("value", array::step::<Int32Type>())
3632            .col("_rowid", array::step::<UInt64Type>())
3633            .into_df_stream(RowCount::from(total_count / 3), BatchCount::from(3));
3634        let full_data_source = Box::pin(RecordBatchStreamAdapter::new(
3635            full_data_gen.schema(),
3636            full_data_gen,
3637        ));
3638
3639        train_btree_index(
3640            full_data_source,
3641            full_store.as_ref(),
3642            DEFAULT_BTREE_BATCH_SIZE,
3643            None,
3644            None,
3645        )
3646        .await
3647        .unwrap();
3648
3649        // Method 2: Build fragment-based index using 3 fragments
3650        // Fragment 1: 0 to DEFAULT_BTREE_BATCH_SIZE-1
3651        let fragment_size = DEFAULT_BTREE_BATCH_SIZE;
3652        let fragment1_gen = gen_batch()
3653            .col("value", array::step::<Int32Type>())
3654            .col("_rowid", array::step::<UInt64Type>())
3655            .into_df_stream(RowCount::from(fragment_size), BatchCount::from(1));
3656        let fragment1_data_source = Box::pin(RecordBatchStreamAdapter::new(
3657            fragment1_gen.schema(),
3658            fragment1_gen,
3659        ));
3660
3661        train_btree_index(
3662            fragment1_data_source,
3663            fragment_store.as_ref(),
3664            DEFAULT_BTREE_BATCH_SIZE,
3665            Some(vec![1]),
3666            None,
3667        )
3668        .await
3669        .unwrap();
3670
3671        // Fragment 2: DEFAULT_BTREE_BATCH_SIZE to 2*DEFAULT_BTREE_BATCH_SIZE-1
3672        let start_val2 = DEFAULT_BTREE_BATCH_SIZE as i32;
3673        let end_val2 = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3674        let values_fragment2: Vec<i32> = (start_val2..end_val2).collect();
3675        let row_ids_fragment2: Vec<u64> = (start_val2 as u64..end_val2 as u64).collect();
3676        let fragment2_gen = gen_batch()
3677            .col("value", array::cycle::<Int32Type>(values_fragment2))
3678            .col("_rowid", array::cycle::<UInt64Type>(row_ids_fragment2))
3679            .into_df_stream(RowCount::from(fragment_size), BatchCount::from(1));
3680        let fragment2_data_source = Box::pin(RecordBatchStreamAdapter::new(
3681            fragment2_gen.schema(),
3682            fragment2_gen,
3683        ));
3684
3685        train_btree_index(
3686            fragment2_data_source,
3687            fragment_store.as_ref(),
3688            DEFAULT_BTREE_BATCH_SIZE,
3689            Some(vec![2]),
3690            None,
3691        )
3692        .await
3693        .unwrap();
3694
3695        // Fragment 3: 2*DEFAULT_BTREE_BATCH_SIZE to 3*DEFAULT_BTREE_BATCH_SIZE-1
3696        let start_val3 = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3697        let end_val3 = (3 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3698        let values_fragment3: Vec<i32> = (start_val3..end_val3).collect();
3699        let row_ids_fragment3: Vec<u64> = (start_val3 as u64..end_val3 as u64).collect();
3700        let fragment3_gen = gen_batch()
3701            .col("value", array::cycle::<Int32Type>(values_fragment3))
3702            .col("_rowid", array::cycle::<UInt64Type>(row_ids_fragment3))
3703            .into_df_stream(RowCount::from(fragment_size), BatchCount::from(1));
3704        let fragment3_data_source = Box::pin(RecordBatchStreamAdapter::new(
3705            fragment3_gen.schema(),
3706            fragment3_gen,
3707        ));
3708
3709        train_btree_index(
3710            fragment3_data_source,
3711            fragment_store.as_ref(),
3712            DEFAULT_BTREE_BATCH_SIZE,
3713            Some(vec![3]),
3714            None,
3715        )
3716        .await
3717        .unwrap();
3718
3719        // Merge all fragment files
3720        let part_page_files = vec![
3721            part_page_data_file_path(1 << 32),
3722            part_page_data_file_path(2 << 32),
3723            part_page_data_file_path(3 << 32),
3724        ];
3725
3726        let part_lookup_files = vec![
3727            part_lookup_file_path(1 << 32),
3728            part_lookup_file_path(2 << 32),
3729            part_lookup_file_path(3 << 32),
3730        ];
3731
3732        super::merge_metadata_files(
3733            fragment_store.as_ref(),
3734            &part_page_files,
3735            &part_lookup_files,
3736            Option::from(1usize),
3737            noop_progress(),
3738        )
3739        .await
3740        .unwrap();
3741
3742        // Load both indexes
3743        let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache())
3744            .await
3745            .unwrap();
3746
3747        let merged_index = BTreeIndex::load(fragment_store.clone(), None, &LanceCache::no_cache())
3748            .await
3749            .unwrap();
3750
3751        // === Boundary Value Tests ===
3752
3753        // Test 1: Query minimum value (boundary: data start)
3754        let query_min = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
3755        let full_result_min = full_index
3756            .search(&query_min, &NoOpMetricsCollector)
3757            .await
3758            .unwrap();
3759        let merged_result_min = merged_index
3760            .search(&query_min, &NoOpMetricsCollector)
3761            .await
3762            .unwrap();
3763        assert_eq!(
3764            full_result_min, merged_result_min,
3765            "Query for minimum value 0 failed"
3766        );
3767
3768        // Test 2: Query maximum value (boundary: data end)
3769        let max_val = (3 * DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
3770        let query_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val)));
3771        let full_result_max = full_index
3772            .search(&query_max, &NoOpMetricsCollector)
3773            .await
3774            .unwrap();
3775        let merged_result_max = merged_index
3776            .search(&query_max, &NoOpMetricsCollector)
3777            .await
3778            .unwrap();
3779        assert_eq!(
3780            full_result_max, merged_result_max,
3781            "Query for maximum value {} failed",
3782            max_val
3783        );
3784
3785        // Test 3: Query fragment boundary value (last value of first fragment)
3786        let fragment1_last = (DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
3787        let query_frag1_last = SargableQuery::Equals(ScalarValue::Int32(Some(fragment1_last)));
3788        let full_result_frag1_last = full_index
3789            .search(&query_frag1_last, &NoOpMetricsCollector)
3790            .await
3791            .unwrap();
3792        let merged_result_frag1_last = merged_index
3793            .search(&query_frag1_last, &NoOpMetricsCollector)
3794            .await
3795            .unwrap();
3796        assert_eq!(
3797            full_result_frag1_last, merged_result_frag1_last,
3798            "Query for fragment 1 last value {} failed",
3799            fragment1_last
3800        );
3801
3802        // Test 4: Query fragment boundary value (first value of second fragment)
3803        let fragment2_first = DEFAULT_BTREE_BATCH_SIZE as i32;
3804        let query_frag2_first = SargableQuery::Equals(ScalarValue::Int32(Some(fragment2_first)));
3805        let full_result_frag2_first = full_index
3806            .search(&query_frag2_first, &NoOpMetricsCollector)
3807            .await
3808            .unwrap();
3809        let merged_result_frag2_first = merged_index
3810            .search(&query_frag2_first, &NoOpMetricsCollector)
3811            .await
3812            .unwrap();
3813        assert_eq!(
3814            full_result_frag2_first, merged_result_frag2_first,
3815            "Query for fragment 2 first value {} failed",
3816            fragment2_first
3817        );
3818
3819        // Test 5: Query fragment boundary value (last value of second fragment)
3820        let fragment2_last = (2 * DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
3821        let query_frag2_last = SargableQuery::Equals(ScalarValue::Int32(Some(fragment2_last)));
3822        let full_result_frag2_last = full_index
3823            .search(&query_frag2_last, &NoOpMetricsCollector)
3824            .await
3825            .unwrap();
3826        let merged_result_frag2_last = merged_index
3827            .search(&query_frag2_last, &NoOpMetricsCollector)
3828            .await
3829            .unwrap();
3830        assert_eq!(
3831            full_result_frag2_last, merged_result_frag2_last,
3832            "Query for fragment 2 last value {} failed",
3833            fragment2_last
3834        );
3835
3836        // Test 6: Query fragment boundary value (first value of third fragment)
3837        let fragment3_first = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3838        let query_frag3_first = SargableQuery::Equals(ScalarValue::Int32(Some(fragment3_first)));
3839        let full_result_frag3_first = full_index
3840            .search(&query_frag3_first, &NoOpMetricsCollector)
3841            .await
3842            .unwrap();
3843        let merged_result_frag3_first = merged_index
3844            .search(&query_frag3_first, &NoOpMetricsCollector)
3845            .await
3846            .unwrap();
3847        assert_eq!(
3848            full_result_frag3_first, merged_result_frag3_first,
3849            "Query for fragment 3 first value {} failed",
3850            fragment3_first
3851        );
3852
3853        // === Non-existent Value Tests ===
3854
3855        // Test 7: Query value below minimum
3856        let query_below_min = SargableQuery::Equals(ScalarValue::Int32(Some(-1)));
3857        let full_result_below = full_index
3858            .search(&query_below_min, &NoOpMetricsCollector)
3859            .await
3860            .unwrap();
3861        let merged_result_below = merged_index
3862            .search(&query_below_min, &NoOpMetricsCollector)
3863            .await
3864            .unwrap();
3865        assert_eq!(
3866            full_result_below, merged_result_below,
3867            "Query for value below minimum (-1) failed"
3868        );
3869
3870        // Test 8: Query value above maximum
3871        let query_above_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val + 1)));
3872        let full_result_above = full_index
3873            .search(&query_above_max, &NoOpMetricsCollector)
3874            .await
3875            .unwrap();
3876        let merged_result_above = merged_index
3877            .search(&query_above_max, &NoOpMetricsCollector)
3878            .await
3879            .unwrap();
3880        assert_eq!(
3881            full_result_above,
3882            merged_result_above,
3883            "Query for value above maximum ({}) failed",
3884            max_val + 1
3885        );
3886
3887        // === Range Query Tests ===
3888
3889        // Test 9: Cross-fragment range query (from first fragment to second fragment)
3890        let range_start = (DEFAULT_BTREE_BATCH_SIZE - 100) as i32;
3891        let range_end = (DEFAULT_BTREE_BATCH_SIZE + 100) as i32;
3892        let query_cross_frag = SargableQuery::Range(
3893            std::collections::Bound::Included(ScalarValue::Int32(Some(range_start))),
3894            std::collections::Bound::Excluded(ScalarValue::Int32(Some(range_end))),
3895        );
3896        let full_result_cross = full_index
3897            .search(&query_cross_frag, &NoOpMetricsCollector)
3898            .await
3899            .unwrap();
3900        let merged_result_cross = merged_index
3901            .search(&query_cross_frag, &NoOpMetricsCollector)
3902            .await
3903            .unwrap();
3904        assert_eq!(
3905            full_result_cross, merged_result_cross,
3906            "Cross-fragment range query [{}, {}] failed",
3907            range_start, range_end
3908        );
3909
3910        // Test 10: Range query within single fragment
3911        let single_frag_start = 100i32;
3912        let single_frag_end = 200i32;
3913        let query_single_frag = SargableQuery::Range(
3914            std::collections::Bound::Included(ScalarValue::Int32(Some(single_frag_start))),
3915            std::collections::Bound::Excluded(ScalarValue::Int32(Some(single_frag_end))),
3916        );
3917        let full_result_single = full_index
3918            .search(&query_single_frag, &NoOpMetricsCollector)
3919            .await
3920            .unwrap();
3921        let merged_result_single = merged_index
3922            .search(&query_single_frag, &NoOpMetricsCollector)
3923            .await
3924            .unwrap();
3925        assert_eq!(
3926            full_result_single, merged_result_single,
3927            "Single fragment range query [{}, {}] failed",
3928            single_frag_start, single_frag_end
3929        );
3930
3931        // Test 11: Large range query spanning all fragments
3932        let large_range_start = 100i32;
3933        let large_range_end = (3 * DEFAULT_BTREE_BATCH_SIZE - 100) as i32;
3934        let query_large_range = SargableQuery::Range(
3935            std::collections::Bound::Included(ScalarValue::Int32(Some(large_range_start))),
3936            std::collections::Bound::Excluded(ScalarValue::Int32(Some(large_range_end))),
3937        );
3938        let full_result_large = full_index
3939            .search(&query_large_range, &NoOpMetricsCollector)
3940            .await
3941            .unwrap();
3942        let merged_result_large = merged_index
3943            .search(&query_large_range, &NoOpMetricsCollector)
3944            .await
3945            .unwrap();
3946        assert_eq!(
3947            full_result_large, merged_result_large,
3948            "Large range query [{}, {}] failed",
3949            large_range_start, large_range_end
3950        );
3951
3952        // === Range Boundary Query Tests ===
3953
3954        // Test 12: Less than query (implemented using range query, from minimum to specified value)
3955        let lt_val = (DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
3956        let query_lt = SargableQuery::Range(
3957            std::collections::Bound::Included(ScalarValue::Int32(Some(0))),
3958            std::collections::Bound::Excluded(ScalarValue::Int32(Some(lt_val))),
3959        );
3960        let full_result_lt = full_index
3961            .search(&query_lt, &NoOpMetricsCollector)
3962            .await
3963            .unwrap();
3964        let merged_result_lt = merged_index
3965            .search(&query_lt, &NoOpMetricsCollector)
3966            .await
3967            .unwrap();
3968        assert_eq!(
3969            full_result_lt, merged_result_lt,
3970            "Less than query (<{}) failed",
3971            lt_val
3972        );
3973
3974        // Test 13: Greater than query (implemented using range query, from specified value to maximum)
3975        let gt_val = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3976        let max_range_val = (3 * DEFAULT_BTREE_BATCH_SIZE) as i32;
3977        let query_gt = SargableQuery::Range(
3978            std::collections::Bound::Excluded(ScalarValue::Int32(Some(gt_val))),
3979            std::collections::Bound::Excluded(ScalarValue::Int32(Some(max_range_val))),
3980        );
3981        let full_result_gt = full_index
3982            .search(&query_gt, &NoOpMetricsCollector)
3983            .await
3984            .unwrap();
3985        let merged_result_gt = merged_index
3986            .search(&query_gt, &NoOpMetricsCollector)
3987            .await
3988            .unwrap();
3989        assert_eq!(
3990            full_result_gt, merged_result_gt,
3991            "Greater than query (>{}) failed",
3992            gt_val
3993        );
3994
3995        // Test 14: Less than or equal query (implemented using range query, including boundary value)
3996        let lte_val = (DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
3997        let query_lte = SargableQuery::Range(
3998            std::collections::Bound::Included(ScalarValue::Int32(Some(0))),
3999            std::collections::Bound::Included(ScalarValue::Int32(Some(lte_val))),
4000        );
4001        let full_result_lte = full_index
4002            .search(&query_lte, &NoOpMetricsCollector)
4003            .await
4004            .unwrap();
4005        let merged_result_lte = merged_index
4006            .search(&query_lte, &NoOpMetricsCollector)
4007            .await
4008            .unwrap();
4009        assert_eq!(
4010            full_result_lte, merged_result_lte,
4011            "Less than or equal query (<={}) failed",
4012            lte_val
4013        );
4014
4015        // Test 15: Greater than or equal query (implemented using range query, including boundary value)
4016        let gte_val = (2 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4017        let query_gte = SargableQuery::Range(
4018            std::collections::Bound::Included(ScalarValue::Int32(Some(gte_val))),
4019            std::collections::Bound::Excluded(ScalarValue::Int32(Some(max_range_val))),
4020        );
4021        let full_result_gte = full_index
4022            .search(&query_gte, &NoOpMetricsCollector)
4023            .await
4024            .unwrap();
4025        let merged_result_gte = merged_index
4026            .search(&query_gte, &NoOpMetricsCollector)
4027            .await
4028            .unwrap();
4029        assert_eq!(
4030            full_result_gte, merged_result_gte,
4031            "Greater than or equal query (>={}) failed",
4032            gte_val
4033        );
4034    }
4035
4036    #[test]
4037    fn test_extract_partition_id() {
4038        // Test valid partition file names
4039        assert_eq!(
4040            super::extract_partition_id("part_123_page_data.lance").unwrap(),
4041            123
4042        );
4043        assert_eq!(
4044            super::extract_partition_id("part_456_page_lookup.lance").unwrap(),
4045            456
4046        );
4047        assert_eq!(
4048            super::extract_partition_id("part_4294967296_page_data.lance").unwrap(),
4049            4294967296
4050        );
4051
4052        // Test invalid file names
4053        assert!(super::extract_partition_id("invalid_filename.lance").is_err());
4054        assert!(super::extract_partition_id("part_abc_page_data.lance").is_err());
4055        assert!(super::extract_partition_id("part_123").is_err());
4056        assert!(super::extract_partition_id("part_").is_err());
4057    }
4058
4059    #[tokio::test]
4060    async fn test_cleanup_partition_files() {
4061        // Create a test store
4062        let tmpdir = TempObjDir::default();
4063        let test_store: Arc<dyn crate::scalar::IndexStore> = Arc::new(LanceIndexStore::new(
4064            Arc::new(ObjectStore::local()),
4065            tmpdir.clone(),
4066            Arc::new(LanceCache::no_cache()),
4067        ));
4068
4069        // Test files with different patterns
4070        let lookup_files = vec![
4071            "part_123_page_lookup.lance".to_string(),
4072            "invalid_lookup_file.lance".to_string(),
4073            "part_456_page_lookup.lance".to_string(),
4074        ];
4075
4076        let page_files = vec![
4077            "part_123_page_data.lance".to_string(),
4078            "invalid_page_file.lance".to_string(),
4079            "part_456_page_data.lance".to_string(),
4080        ];
4081
4082        // The cleanup function should handle both valid and invalid file patterns gracefully
4083        // This test mainly verifies that the function doesn't panic and handles edge cases
4084        super::cleanup_partition_files(test_store.as_ref(), &lookup_files, &page_files).await;
4085    }
4086
4087    #[tokio::test]
4088    async fn test_btree_null_handling_in_queries() {
4089        let store = Arc::new(LanceIndexStore::new(
4090            Arc::new(ObjectStore::memory()),
4091            Path::default(),
4092            Arc::new(LanceCache::no_cache()),
4093        ));
4094
4095        // Create test data: [null, 0, 5] at row IDs [0, 1, 2]
4096        // BTree expects sorted data with nulls first (or filtered out)
4097        let batch = record_batch!(
4098            ("value", Int32, [None, Some(0), Some(5)]),
4099            ("_rowid", UInt64, [0, 1, 2])
4100        )
4101        .unwrap();
4102        let stream = stream::once(futures::future::ok(batch.clone()));
4103        let stream = Box::pin(RecordBatchStreamAdapter::new(batch.schema(), stream));
4104
4105        // Train the btree index with FlatIndexMetadata as sub-index
4106        super::train_btree_index(stream, store.as_ref(), 256, None, None)
4107            .await
4108            .unwrap();
4109
4110        let cache = LanceCache::with_capacity(1024 * 1024);
4111        let index = super::BTreeIndex::load(store.clone(), None, &cache)
4112            .await
4113            .unwrap();
4114
4115        // Test 1: Search for value 5 - should return allow=[2], null=[0]
4116        let query = SargableQuery::Equals(ScalarValue::Int32(Some(5)));
4117        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
4118
4119        match result {
4120            SearchResult::Exact(row_ids) => {
4121                let actual_rows: Vec<u64> = row_ids
4122                    .true_rows()
4123                    .row_addrs()
4124                    .unwrap()
4125                    .map(u64::from)
4126                    .collect();
4127                assert_eq!(actual_rows, vec![2], "Should find row 2 where value == 5");
4128
4129                // Check that null_row_ids contains row 0
4130                let null_row_ids = row_ids.null_rows();
4131                assert!(!null_row_ids.is_empty(), "null_row_ids should be non-empty");
4132                let null_rows: Vec<u64> =
4133                    null_row_ids.row_addrs().unwrap().map(u64::from).collect();
4134                assert_eq!(null_rows, vec![0], "Should report row 0 as null");
4135            }
4136            _ => panic!("Expected Exact search result"),
4137        }
4138
4139        // Test 2: Range query [0, 3] - should return allow=[1], null=[0]
4140        let query = SargableQuery::Range(
4141            std::ops::Bound::Included(ScalarValue::Int32(Some(0))),
4142            std::ops::Bound::Included(ScalarValue::Int32(Some(3))),
4143        );
4144        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
4145
4146        match result {
4147            SearchResult::Exact(row_ids) => {
4148                let actual_rows: Vec<u64> = row_ids
4149                    .true_rows()
4150                    .row_addrs()
4151                    .unwrap()
4152                    .map(u64::from)
4153                    .collect();
4154                assert_eq!(actual_rows, vec![1], "Should find row 1 where value == 0");
4155
4156                // Should report row 0 as null
4157                let null_row_ids = row_ids.null_rows();
4158                assert!(!null_row_ids.is_empty(), "null_row_ids should be non-empty");
4159                let null_rows: Vec<u64> =
4160                    null_row_ids.row_addrs().unwrap().map(u64::from).collect();
4161                assert_eq!(null_rows, vec![0], "Should report row 0 as null");
4162            }
4163            _ => panic!("Expected Exact search result"),
4164        }
4165
4166        // Test 3: IsIn query [0, 5] - should return allow=[1, 2], null=[0]
4167        let query = SargableQuery::IsIn(vec![
4168            ScalarValue::Int32(Some(0)),
4169            ScalarValue::Int32(Some(5)),
4170        ]);
4171        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
4172
4173        match result {
4174            SearchResult::Exact(row_ids) => {
4175                let mut actual_rows: Vec<u64> = row_ids
4176                    .true_rows()
4177                    .row_addrs()
4178                    .unwrap()
4179                    .map(u64::from)
4180                    .collect();
4181                actual_rows.sort();
4182                assert_eq!(
4183                    actual_rows,
4184                    vec![1, 2],
4185                    "Should find rows 1 and 2 where value in [0, 5]"
4186                );
4187
4188                // Should report row 0 as null
4189                let null_row_ids = row_ids.null_rows();
4190                assert!(!null_row_ids.is_empty(), "null_row_ids should be non-empty");
4191                let null_rows: Vec<u64> =
4192                    null_row_ids.row_addrs().unwrap().map(u64::from).collect();
4193                assert_eq!(null_rows, vec![0], "Should report row 0 as null");
4194            }
4195            _ => panic!("Expected Exact search result"),
4196        }
4197    }
4198
4199    #[tokio::test]
4200    async fn test_range_btree_index_consistency() {
4201        // Setup stores for both indexes
4202        let full_tmpdir = TempObjDir::default();
4203        let full_store = Arc::new(LanceIndexStore::new(
4204            Arc::new(ObjectStore::local()),
4205            full_tmpdir.clone(),
4206            Arc::new(LanceCache::no_cache()),
4207        ));
4208
4209        let range_tmpdir = TempObjDir::default();
4210        let range_store = Arc::new(LanceIndexStore::new(
4211            Arc::new(ObjectStore::local()),
4212            range_tmpdir.clone(),
4213            Arc::new(LanceCache::no_cache()),
4214        ));
4215
4216        // Method 1: Build complete index directly using the same data
4217        // Create deterministic data for comparison - use 4 * DEFAULT_BTREE_BATCH_SIZE for testing
4218        let total_count = 4 * DEFAULT_BTREE_BATCH_SIZE;
4219        let full_data_gen = gen_batch()
4220            .col("value", array::step::<Int32Type>())
4221            .col("_rowid", array::step::<UInt64Type>())
4222            .into_df_stream(RowCount::from(total_count / 4), BatchCount::from(4));
4223        let full_data_source = Box::pin(RecordBatchStreamAdapter::new(
4224            full_data_gen.schema(),
4225            full_data_gen,
4226        ));
4227
4228        train_btree_index(
4229            full_data_source,
4230            full_store.as_ref(),
4231            DEFAULT_BTREE_BATCH_SIZE,
4232            None,
4233            None,
4234        )
4235        .await
4236        .unwrap();
4237
4238        // Method 2: Build range-based index using the same data split into ranges
4239        // Create range 1 index, intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
4240        let range1_gen = gen_batch()
4241            .col("value", array::step::<Int32Type>())
4242            .col("_rowid", array::step::<UInt64Type>())
4243            .into_df_stream(
4244                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4245                BatchCount::from(5),
4246            );
4247        let range1_data_source = Box::pin(RecordBatchStreamAdapter::new(
4248            range1_gen.schema(),
4249            range1_gen,
4250        ));
4251
4252        train_btree_index(
4253            range1_data_source,
4254            range_store.as_ref(),
4255            DEFAULT_BTREE_BATCH_SIZE,
4256            None,
4257            Option::from(0u32),
4258        )
4259        .await
4260        .unwrap();
4261
4262        // Create range 2 index, also intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
4263        let start_val = (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4264        let end_val = (4 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4265        let values_second_half: Vec<i32> = (start_val..end_val).collect();
4266        let row_ids_second_half: Vec<u64> = (start_val as u64..end_val as u64).collect();
4267        let range2_gen = gen_batch()
4268            .col("value", array::cycle::<Int32Type>(values_second_half))
4269            .col("_rowid", array::cycle::<UInt64Type>(row_ids_second_half))
4270            .into_df_stream(
4271                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4272                BatchCount::from(3),
4273            );
4274        let range2_data_source = Box::pin(RecordBatchStreamAdapter::new(
4275            range2_gen.schema(),
4276            range2_gen,
4277        ));
4278
4279        train_btree_index(
4280            range2_data_source,
4281            range_store.as_ref(),
4282            DEFAULT_BTREE_BATCH_SIZE,
4283            None,
4284            Option::from(1u32),
4285        )
4286        .await
4287        .unwrap();
4288
4289        // Merge the fragment files
4290        let part_page_files = vec![
4291            part_page_data_file_path(0 << 32),
4292            part_page_data_file_path(1 << 32),
4293        ];
4294
4295        let part_lookup_files = vec![
4296            part_lookup_file_path(0 << 32),
4297            part_lookup_file_path(1 << 32),
4298        ];
4299
4300        super::merge_metadata_files(
4301            range_store.as_ref(),
4302            &part_page_files,
4303            &part_lookup_files,
4304            Option::from(1usize),
4305            noop_progress(),
4306        )
4307        .await
4308        .unwrap();
4309
4310        let full_index = BTreeIndex::load(full_store.clone(), None, &LanceCache::no_cache())
4311            .await
4312            .unwrap();
4313
4314        let ranged_index = BTreeIndex::load(range_store.clone(), None, &LanceCache::no_cache())
4315            .await
4316            .unwrap();
4317
4318        // Equality Tests
4319
4320        // Test 1: Query for value 0
4321        let query_0 = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
4322        let full_result_0 = full_index
4323            .search(&query_0, &NoOpMetricsCollector)
4324            .await
4325            .unwrap();
4326        let ranged_result_0 = ranged_index
4327            .search(&query_0, &NoOpMetricsCollector)
4328            .await
4329            .unwrap();
4330        assert_eq!(full_result_0, ranged_result_0, "Query for value 0 failed");
4331
4332        // Test 2: Query for value in middle of first batch (should be in first page)
4333        let mid_first_batch = (DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4334        let query_mid_first = SargableQuery::Equals(ScalarValue::Int32(Some(mid_first_batch)));
4335        let full_result_mid_first = full_index
4336            .search(&query_mid_first, &NoOpMetricsCollector)
4337            .await
4338            .unwrap();
4339        let ranged_result_mid_first = ranged_index
4340            .search(&query_mid_first, &NoOpMetricsCollector)
4341            .await
4342            .unwrap();
4343        assert_eq!(
4344            full_result_mid_first, ranged_result_mid_first,
4345            "Query for value {} failed",
4346            mid_first_batch
4347        );
4348
4349        // Test 3: Query for value in the last batch (should be in the second range file)
4350        let mid_last_batch = (DEFAULT_BTREE_BATCH_SIZE * 3 + (DEFAULT_BTREE_BATCH_SIZE / 2)) as i32;
4351        let query_mid_last = SargableQuery::Equals(ScalarValue::Int32(Some(mid_last_batch)));
4352        let full_result_mid_last = full_index
4353            .search(&query_mid_last, &NoOpMetricsCollector)
4354            .await
4355            .unwrap();
4356        let ranged_result_mid_last = ranged_index
4357            .search(&query_mid_last, &NoOpMetricsCollector)
4358            .await
4359            .unwrap();
4360        assert_eq!(
4361            full_result_mid_last, ranged_result_mid_last,
4362            "Query for value {} failed",
4363            mid_last_batch
4364        );
4365
4366        // Test 4: Query upper bound.
4367        let max_val = (4 * DEFAULT_BTREE_BATCH_SIZE - 1) as i32;
4368        let query_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val)));
4369        let full_result_max = full_index
4370            .search(&query_max, &NoOpMetricsCollector)
4371            .await
4372            .unwrap();
4373        let ranged_result_max = ranged_index
4374            .search(&query_max, &NoOpMetricsCollector)
4375            .await
4376            .unwrap();
4377        assert_eq!(
4378            full_result_max, ranged_result_max,
4379            "Query for maximum value {} failed",
4380            max_val
4381        );
4382
4383        // Test 5: Query first value of the second page file.
4384        let second_first_val = (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4385        let query_second_first = SargableQuery::Equals(ScalarValue::Int32(Some(second_first_val)));
4386        let full_result_second_first = full_index
4387            .search(&query_second_first, &NoOpMetricsCollector)
4388            .await
4389            .unwrap();
4390        let ranged_result_second_first = ranged_index
4391            .search(&query_second_first, &NoOpMetricsCollector)
4392            .await
4393            .unwrap();
4394        assert_eq!(
4395            full_result_second_first, ranged_result_second_first,
4396            "Query for first value of the second page file {} failed",
4397            second_first_val
4398        );
4399
4400        // Test 6: Query value below the minimum
4401        let query_below_min = SargableQuery::Equals(ScalarValue::Int32(Some(-1)));
4402        let full_result_below = full_index
4403            .search(&query_below_min, &NoOpMetricsCollector)
4404            .await
4405            .unwrap();
4406        let ranged_result_below = ranged_index
4407            .search(&query_below_min, &NoOpMetricsCollector)
4408            .await
4409            .unwrap();
4410        assert_eq!(
4411            full_result_below, ranged_result_below,
4412            "Query for value below minimum (-1) failed"
4413        );
4414
4415        // Test 7: Query value above the maximum
4416        let query_above_max = SargableQuery::Equals(ScalarValue::Int32(Some(max_val + 1)));
4417        let full_result_above = full_index
4418            .search(&query_above_max, &NoOpMetricsCollector)
4419            .await
4420            .unwrap();
4421        let ranged_result_above = ranged_index
4422            .search(&query_above_max, &NoOpMetricsCollector)
4423            .await
4424            .unwrap();
4425        assert_eq!(
4426            full_result_above,
4427            ranged_result_above,
4428            "Query for value above maximum ({}) failed",
4429            max_val + 1
4430        );
4431
4432        // Range Tests
4433
4434        // Test 8: Cross-range query: One range including different values from adjacent range files.
4435        let range_start =
4436            (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2 - 100) as i32;
4437        let range_end = range_start + 200;
4438        let query_cross_range = SargableQuery::Range(
4439            std::collections::Bound::Included(ScalarValue::Int32(Some(range_start))),
4440            std::collections::Bound::Excluded(ScalarValue::Int32(Some(range_end))),
4441        );
4442        let full_result_cross = full_index
4443            .search(&query_cross_range, &NoOpMetricsCollector)
4444            .await
4445            .unwrap();
4446        let ranged_result_cross = ranged_index
4447            .search(&query_cross_range, &NoOpMetricsCollector)
4448            .await
4449            .unwrap();
4450        assert_eq!(
4451            full_result_cross, ranged_result_cross,
4452            "Cross-range range query [{}, {}] failed",
4453            range_start, range_end
4454        );
4455
4456        // Test 9 Test simple range within a single page file
4457        let single_range_start = (DEFAULT_BTREE_BATCH_SIZE * 4 - 300) as i32;
4458        let single_range_end = single_range_start + 200;
4459        let query_single_range = SargableQuery::Range(
4460            std::collections::Bound::Included(ScalarValue::Int32(Some(single_range_start))),
4461            std::collections::Bound::Excluded(ScalarValue::Int32(Some(single_range_end))),
4462        );
4463        let full_result_single = full_index
4464            .search(&query_single_range, &NoOpMetricsCollector)
4465            .await
4466            .unwrap();
4467        let ranged_result_single = ranged_index
4468            .search(&query_single_range, &NoOpMetricsCollector)
4469            .await
4470            .unwrap();
4471        assert_eq!(
4472            full_result_single, ranged_result_single,
4473            "Single range query [{}, {}] failed",
4474            single_range_start, single_range_end
4475        );
4476
4477        // Test 10: Large range query spanning almost all values
4478        let large_range_start = 100_i32;
4479        let large_range_end = (DEFAULT_BTREE_BATCH_SIZE * 4 - 100) as i32;
4480        let query_large_range = SargableQuery::Range(
4481            std::collections::Bound::Included(ScalarValue::Int32(Some(large_range_start))),
4482            std::collections::Bound::Excluded(ScalarValue::Int32(Some(large_range_end))),
4483        );
4484        let full_result_single = full_index
4485            .search(&query_large_range, &NoOpMetricsCollector)
4486            .await
4487            .unwrap();
4488        let ranged_result_single = ranged_index
4489            .search(&query_large_range, &NoOpMetricsCollector)
4490            .await
4491            .unwrap();
4492        assert_eq!(
4493            full_result_single, ranged_result_single,
4494            "Single fragment range query [{}, {}] failed",
4495            large_range_start, large_range_end
4496        );
4497
4498        let remap_dir = TempObjDir::default();
4499        let remap_store = Arc::new(LanceIndexStore::new(
4500            Arc::new(ObjectStore::local()),
4501            remap_dir.clone(),
4502            Arc::new(LanceCache::no_cache()),
4503        ));
4504
4505        // Remap with a no-op mapping.  The remapped index should be identical to the original
4506        ranged_index
4507            .remap(&HashMap::default(), remap_store.as_ref())
4508            .await
4509            .unwrap();
4510
4511        let remap_index = BTreeIndex::load(remap_store.clone(), None, &LanceCache::no_cache())
4512            .await
4513            .unwrap();
4514
4515        assert_eq!(remap_index.page_lookup, ranged_index.page_lookup);
4516
4517        let ranged_pages = range_store
4518            .open_index_file(part_page_data_file_path(1 << 32).as_str())
4519            .await
4520            .unwrap();
4521        let remapped_pages = remap_store
4522            .open_index_file(part_page_data_file_path(1 << 32).as_str())
4523            .await
4524            .unwrap();
4525
4526        assert_eq!(ranged_pages.num_rows(), remapped_pages.num_rows());
4527
4528        let original_data = ranged_pages
4529            .read_record_batch(0, ranged_pages.num_rows() as u64)
4530            .await
4531            .unwrap();
4532        let remapped_data = remapped_pages
4533            .read_record_batch(0, remapped_pages.num_rows() as u64)
4534            .await
4535            .unwrap();
4536
4537        assert_eq!(original_data, remapped_data);
4538    }
4539
4540    #[tokio::test]
4541    async fn test_update_ranged_index() {
4542        // Setup stores for both indexes
4543        let old_tmpdir = TempObjDir::default();
4544        let old_store = Arc::new(LanceIndexStore::new(
4545            Arc::new(ObjectStore::local()),
4546            old_tmpdir.clone(),
4547            Arc::new(LanceCache::no_cache()),
4548        ));
4549
4550        let new_tmpdir = TempObjDir::default();
4551        let new_store = Arc::new(LanceIndexStore::new(
4552            Arc::new(ObjectStore::local()),
4553            new_tmpdir.clone(),
4554            Arc::new(LanceCache::no_cache()),
4555        ));
4556
4557        // Create range 1 index, intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
4558        let range1_gen = gen_batch()
4559            .col("value", array::step::<Int32Type>())
4560            .col("_rowid", array::step::<UInt64Type>())
4561            .into_df_stream(
4562                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4563                BatchCount::from(5),
4564            );
4565        let range1_data_source = Box::pin(RecordBatchStreamAdapter::new(
4566            range1_gen.schema(),
4567            range1_gen,
4568        ));
4569
4570        train_btree_index(
4571            range1_data_source,
4572            old_store.as_ref(),
4573            DEFAULT_BTREE_BATCH_SIZE,
4574            None,
4575            Option::from(1u32),
4576        )
4577        .await
4578        .unwrap();
4579
4580        // Create range 2 index, also intentionally make it not divisible by DEFAULT_BTREE_BATCH_SIZE
4581        let start_val = (DEFAULT_BTREE_BATCH_SIZE * 2 + DEFAULT_BTREE_BATCH_SIZE / 2) as i32;
4582        let end_val = (4 * DEFAULT_BTREE_BATCH_SIZE) as i32;
4583        let values_second_half: Vec<i32> = (start_val..end_val).collect();
4584        let row_ids_second_half: Vec<u64> = (start_val as u64..end_val as u64).collect();
4585        let range2_gen = gen_batch()
4586            .col("value", array::cycle::<Int32Type>(values_second_half))
4587            .col("_rowid", array::cycle::<UInt64Type>(row_ids_second_half))
4588            .into_df_stream(
4589                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4590                BatchCount::from(3),
4591            );
4592        let range2_data_source = Box::pin(RecordBatchStreamAdapter::new(
4593            range2_gen.schema(),
4594            range2_gen,
4595        ));
4596
4597        train_btree_index(
4598            range2_data_source,
4599            old_store.as_ref(),
4600            DEFAULT_BTREE_BATCH_SIZE,
4601            None,
4602            Option::from(2u32),
4603        )
4604        .await
4605        .unwrap();
4606
4607        // Merge the fragment files
4608        let part_page_files = vec![
4609            part_page_data_file_path(1 << 32),
4610            part_page_data_file_path(2 << 32),
4611        ];
4612
4613        let part_lookup_files = vec![
4614            part_lookup_file_path(1 << 32),
4615            part_lookup_file_path(2 << 32),
4616        ];
4617
4618        super::merge_metadata_files(
4619            old_store.as_ref(),
4620            &part_page_files,
4621            &part_lookup_files,
4622            Option::from(1usize),
4623            noop_progress(),
4624        )
4625        .await
4626        .unwrap();
4627
4628        // create some update data
4629        let start_val = (DEFAULT_BTREE_BATCH_SIZE * 2) as i32;
4630        let end_val = (DEFAULT_BTREE_BATCH_SIZE * 3) as i32;
4631        let row_id_delta = (DEFAULT_BTREE_BATCH_SIZE * 3) as i32;
4632        let values: Vec<i32> = (start_val..end_val).collect();
4633        let row_ids: Vec<u64> =
4634            ((start_val + row_id_delta) as u64..(end_val + row_id_delta) as u64).collect();
4635        let update_data = gen_batch()
4636            .col("value", array::cycle::<Int32Type>(values))
4637            .col("_rowid", array::cycle::<UInt64Type>(row_ids))
4638            .into_df_stream(
4639                RowCount::from(DEFAULT_BTREE_BATCH_SIZE / 2),
4640                BatchCount::from(2),
4641            );
4642        let update_data_source = Box::pin(RecordBatchStreamAdapter::new(
4643            update_data.schema(),
4644            update_data,
4645        ));
4646
4647        let ranged_index = BTreeIndex::load(old_store.clone(), None, &LanceCache::no_cache())
4648            .await
4649            .unwrap();
4650
4651        // update the ranged index
4652        ranged_index
4653            .update(update_data_source, new_store.as_ref(), None)
4654            .await
4655            .expect("Error in updating ranged index");
4656
4657        let updated_index = BTreeIndex::load(new_store.clone(), None, &LanceCache::no_cache())
4658            .await
4659            .unwrap();
4660
4661        assert!(
4662            updated_index.ranges_to_files.is_none(),
4663            "Updated ranged-btree-index should fall back to non-ranged"
4664        );
4665
4666        let updated_value = (DEFAULT_BTREE_BATCH_SIZE * 2 + (DEFAULT_BTREE_BATCH_SIZE / 2)) as i32;
4667        let updated_query = SargableQuery::Equals(ScalarValue::Int32(Some(updated_value)));
4668
4669        let query_result = updated_index
4670            .search(&updated_query, &NoOpMetricsCollector)
4671            .await
4672            .unwrap();
4673        match query_result {
4674            SearchResult::Exact(row_id_map) => {
4675                assert!(
4676                    row_id_map.selected(updated_value as u64),
4677                    "Updated index should contain original rowids."
4678                );
4679                assert!(
4680                    row_id_map.selected((updated_value + row_id_delta) as u64),
4681                    "Updated index should contain new rowids"
4682                );
4683            }
4684            _ => {
4685                panic!("Btree search result should always be Exact.");
4686            }
4687        }
4688    }
4689
4690    #[tokio::test]
4691    async fn test_update_with_exact_row_id_filter() {
4692        let old_tmpdir = TempObjDir::default();
4693        let old_store = Arc::new(LanceIndexStore::new(
4694            Arc::new(ObjectStore::local()),
4695            old_tmpdir.clone(),
4696            Arc::new(LanceCache::no_cache()),
4697        ));
4698
4699        let new_tmpdir = TempObjDir::default();
4700        let new_store = Arc::new(LanceIndexStore::new(
4701            Arc::new(ObjectStore::local()),
4702            new_tmpdir.clone(),
4703            Arc::new(LanceCache::no_cache()),
4704        ));
4705
4706        let old_data = gen_batch()
4707            .col("value", array::step::<Int32Type>())
4708            .col("_rowid", array::step::<UInt64Type>())
4709            .into_df_stream(RowCount::from(512), BatchCount::from(2));
4710        let old_data_source = Box::pin(RecordBatchStreamAdapter::new(old_data.schema(), old_data));
4711        train_btree_index(
4712            old_data_source,
4713            old_store.as_ref(),
4714            DEFAULT_BTREE_BATCH_SIZE,
4715            None,
4716            None,
4717        )
4718        .await
4719        .unwrap();
4720
4721        let index = BTreeIndex::load(old_store.clone(), None, &LanceCache::no_cache())
4722            .await
4723            .unwrap();
4724
4725        let new_data = gen_batch()
4726            .col("value", array::step_custom::<Int32Type>(2000, 1))
4727            .col("_rowid", array::step_custom::<UInt64Type>(2000, 1))
4728            .into_df_stream(RowCount::from(100), BatchCount::from(1));
4729        let new_data_source = Box::pin(RecordBatchStreamAdapter::new(new_data.schema(), new_data));
4730
4731        let mut retained_old_rows = RowAddrTreeMap::new();
4732        retained_old_rows.insert_range(0..64);
4733        retained_old_rows.insert_range(300..364);
4734
4735        index
4736            .update(
4737                new_data_source,
4738                new_store.as_ref(),
4739                Some(OldIndexDataFilter::RowIds(retained_old_rows)),
4740            )
4741            .await
4742            .unwrap();
4743
4744        let updated_index = BTreeIndex::load(new_store.clone(), None, &LanceCache::no_cache())
4745            .await
4746            .unwrap();
4747
4748        let present = |value: i32| {
4749            let updated_index = updated_index.clone();
4750            async move {
4751                let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
4752                match updated_index
4753                    .search(&query, &NoOpMetricsCollector)
4754                    .await
4755                    .unwrap()
4756                {
4757                    SearchResult::Exact(row_id_map) => row_id_map.selected(value as u64),
4758                    _ => unreachable!("Btree search result should always be Exact"),
4759                }
4760            }
4761        };
4762
4763        assert!(present(12).await);
4764        assert!(present(320).await);
4765        assert!(!present(120).await);
4766        assert!(!present(420).await);
4767        assert!(present(2005).await);
4768    }
4769
4770    /// Rust equivalent of Python test `test_btree_remap_big_deletions`
4771    ///
4772    /// This test verifies that btree index remapping works correctly when a large
4773    /// portion of the data is deleted. The Python test:
4774    /// 1. Writes 15K rows in 3 fragments (values 0-14999)
4775    /// 2. Creates a btree index (will have multiple pages)
4776    /// 3. Deletes rows where a > 1000 AND a < 10000 (deletes values 1001-9999)
4777    /// 4. Runs compaction (materializes deletions via remap)
4778    /// 5. Verifies the index still works for remaining values
4779    #[tokio::test]
4780    async fn test_btree_remap_big_deletions() {
4781        let tmpdir = TempObjDir::default();
4782        let test_store = Arc::new(LanceIndexStore::new(
4783            Arc::new(ObjectStore::local()),
4784            tmpdir.clone(),
4785            Arc::new(LanceCache::no_cache()),
4786        ));
4787
4788        // Generate 15000 rows with values 0-14999 and row_ids 0-14999
4789        // Using a smaller batch size to ensure we get multiple pages
4790        let batch_size = 4096;
4791        let total_rows = 15000;
4792
4793        let stream = gen_batch()
4794            .col("value", array::step::<Int32Type>())
4795            .col("_rowid", array::step::<UInt64Type>())
4796            .into_df_stream(RowCount::from(total_rows), BatchCount::from(1));
4797
4798        train_btree_index(stream, test_store.as_ref(), batch_size, None, None)
4799            .await
4800            .unwrap();
4801
4802        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
4803            .await
4804            .unwrap();
4805
4806        // Create a mapping that simulates deleting rows where value > 1000 AND value < 10000
4807        // Since values match row_ids in our test data:
4808        // - Rows 0-1000 (values 0-1000) are kept with same row_ids
4809        // - Rows 1001-9999 (values 1001-9999) are deleted (mapped to None)
4810        // - Rows 10000-14999 (values 10000-14999) are remapped to new row_ids 1001-5999
4811        let mut mapping: HashMap<u64, Option<u64>> = HashMap::new();
4812
4813        // Mark deleted rows (values 1001-9999)
4814        for old_id in 1001..10000 {
4815            mapping.insert(old_id, None);
4816        }
4817
4818        let mut new_id_counter = 100_000;
4819
4820        // Remap all other rows
4821        for old_id in (0..1000).chain(10000..15000) {
4822            let new_id = new_id_counter;
4823            new_id_counter += 1;
4824            mapping.insert(old_id, Some(new_id));
4825        }
4826
4827        let remap_dir = TempObjDir::default();
4828        let remap_store = Arc::new(LanceIndexStore::new(
4829            Arc::new(ObjectStore::local()),
4830            remap_dir.clone(),
4831            Arc::new(LanceCache::no_cache()),
4832        ));
4833
4834        // Remap the index with our deletion mapping
4835        index.remap(&mapping, remap_store.as_ref()).await.unwrap();
4836
4837        let remapped_index = BTreeIndex::load(remap_store.clone(), None, &LanceCache::no_cache())
4838            .await
4839            .unwrap();
4840
4841        // Verify values that should exist (values 0-1000 and 10000-14999)
4842        // These correspond to: original values 0-1000 at row_ids 0-1000
4843        // and original values 10000-14999 at new row_ids 1001-5999
4844        let should_exist = vec![0, 500, 1000, 10000, 13000, 14000, 14999];
4845        for value in should_exist {
4846            let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
4847            let result = remapped_index
4848                .search(&query, &NoOpMetricsCollector)
4849                .await
4850                .unwrap();
4851            match result {
4852                SearchResult::Exact(row_id_map) => {
4853                    assert!(
4854                        !row_id_map.is_empty(),
4855                        "Value {} should exist in remapped index but was not found",
4856                        value
4857                    );
4858                }
4859                _ => {
4860                    panic!("Btree search result should always be Exact.");
4861                }
4862            }
4863        }
4864
4865        // Verify values that should NOT exist (values 1001-9999 were deleted)
4866        let should_not_exist = vec![1001, 5000, 8000, 9999];
4867        for value in should_not_exist {
4868            let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
4869            let result = remapped_index
4870                .search(&query, &NoOpMetricsCollector)
4871                .await
4872                .unwrap();
4873            match result {
4874                SearchResult::Exact(row_id_map) => {
4875                    assert!(
4876                        row_id_map.is_empty(),
4877                        "Value {} should NOT exist in remapped index but was found",
4878                        value
4879                    );
4880                }
4881                _ => {
4882                    panic!("Btree search result should always be Exact.");
4883                }
4884            }
4885        }
4886    }
4887
4888    /// Regression test: BTree search must track null row IDs for non-IsNull
4889    /// queries, even when no pages match the queried value.
4890    ///
4891    /// Without this, `NOT(x = val)` when `val` is absent from the data would
4892    /// produce an empty null set, causing NULL rows to incorrectly pass.
4893    #[tokio::test]
4894    async fn test_search_tracks_nulls_for_absent_value() {
4895        use arrow_array::{Int32Array, UInt64Array};
4896
4897        let tmpdir = TempObjDir::default();
4898        let test_store = Arc::new(LanceIndexStore::new(
4899            Arc::new(ObjectStore::local()),
4900            tmpdir.clone(),
4901            Arc::new(LanceCache::no_cache()),
4902        ));
4903
4904        // Create data with 80% nulls so that training produces separate
4905        // all-null pages (which are not in the BTree map). Non-null values
4906        // are all in [100, 5099], so value 0 never appears.
4907        let num_rows = 5000u64;
4908        let values: Int32Array = (0..num_rows)
4909            .map(|i| {
4910                if i % 5 != 0 {
4911                    None // 80% null
4912                } else {
4913                    Some(100 + i as i32) // non-null values in [100, 5099]
4914                }
4915            })
4916            .collect();
4917        let row_ids = UInt64Array::from_iter_values(0..num_rows);
4918        let data = arrow_array::RecordBatch::try_from_iter(vec![
4919            ("value", Arc::new(values) as arrow_array::ArrayRef),
4920            ("_rowid", Arc::new(row_ids) as arrow_array::ArrayRef),
4921        ])
4922        .unwrap();
4923
4924        let schema = data.schema();
4925        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
4926            schema,
4927            stream::iter(vec![Ok(data)]),
4928        ));
4929        train_btree_index(stream, test_store.as_ref(), num_rows, None, None)
4930            .await
4931            .unwrap();
4932
4933        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
4934            .await
4935            .unwrap();
4936
4937        // Verify we have all-null pages (the bug depends on this)
4938        assert!(
4939            !index.page_lookup.all_null_pages.is_empty(),
4940            "Test setup requires all-null pages; got null_pages={}, all_null_pages={}",
4941            index.page_lookup.null_pages.len(),
4942            index.page_lookup.all_null_pages.len(),
4943        );
4944
4945        let metrics = NoOpMetricsCollector;
4946
4947        // Search for Equals(0) — value 0 doesn't exist in any page
4948        let result = index
4949            .search(
4950                &SargableQuery::Equals(ScalarValue::Int32(Some(0))),
4951                &metrics,
4952            )
4953            .await
4954            .unwrap();
4955
4956        match result {
4957            SearchResult::Exact(set) => {
4958                // No rows should be TRUE (value 0 doesn't exist)
4959                assert!(set.true_rows().is_empty(), "No rows should match Equals(0)");
4960                // NULL rows MUST be tracked as null
4961                assert!(
4962                    !set.null_rows().is_empty(),
4963                    "Null rows must be tracked even when no pages match the value"
4964                );
4965            }
4966            _ => panic!("BTree search should return Exact"),
4967        }
4968
4969        // Also verify Range query tracks nulls when no values match
4970        let result = index
4971            .search(
4972                &SargableQuery::Range(
4973                    std::ops::Bound::Unbounded,
4974                    std::ops::Bound::Excluded(ScalarValue::Int32(Some(50))),
4975                ),
4976                &metrics,
4977            )
4978            .await
4979            .unwrap();
4980
4981        match result {
4982            SearchResult::Exact(set) => {
4983                assert!(set.true_rows().is_empty(), "No rows should be < 50");
4984                assert!(
4985                    !set.null_rows().is_empty(),
4986                    "Null rows must be tracked for range queries too"
4987                );
4988            }
4989            _ => panic!("BTree search should return Exact"),
4990        }
4991    }
4992
4993    fn sample_lookup_batch() -> RecordBatch {
4994        record_batch!(
4995            ("min", Int32, [Some(0), Some(10), Some(20)]),
4996            ("max", Int32, [Some(9), Some(19), Some(29)]),
4997            ("null_count", UInt32, [0, 2, 0]),
4998            ("page_idx", UInt32, [0, 1, 2])
4999        )
5000        .unwrap()
5001    }
5002
5003    fn assert_state_roundtrips(state: &BTreeIndexState) {
5004        let mut buf = Vec::new();
5005        state.serialize(&mut buf).unwrap();
5006        let restored = BTreeIndexState::deserialize(&bytes::Bytes::from(buf)).unwrap();
5007        assert_eq!(restored.lookup_batch, state.lookup_batch);
5008        assert_eq!(restored.batch_size, state.batch_size);
5009        assert_eq!(restored.ranges_to_files, state.ranges_to_files);
5010    }
5011
5012    #[test]
5013    fn test_btree_page_key_codec() {
5014        // FlatIndex pages can be serialized by a persistent cache backend.
5015        assert!(BTreePageKey::codec().is_some());
5016    }
5017
5018    #[test]
5019    fn test_btree_index_state_roundtrip() {
5020        // Not range-partitioned.
5021        assert_state_roundtrips(&BTreeIndexState {
5022            lookup_batch: sample_lookup_batch(),
5023            batch_size: DEFAULT_BTREE_BATCH_SIZE,
5024            ranges_to_files: None,
5025        });
5026
5027        // Range-partitioned across multiple files.
5028        let ranges: RangeInclusiveMap<u32, (String, u32)> = [
5029            (0..=99, ("part_0_page_file.lance".to_string(), 0)),
5030            (100..=199, ("part_1_page_file.lance".to_string(), 100)),
5031        ]
5032        .into_iter()
5033        .collect();
5034        assert_state_roundtrips(&BTreeIndexState {
5035            lookup_batch: sample_lookup_batch(),
5036            batch_size: 8192,
5037            ranges_to_files: Some(Arc::new(ranges)),
5038        });
5039
5040        // Empty index.
5041        assert_state_roundtrips(&BTreeIndexState {
5042            lookup_batch: RecordBatch::new_empty(sample_lookup_batch().schema()),
5043            batch_size: DEFAULT_BTREE_BATCH_SIZE,
5044            ranges_to_files: None,
5045        });
5046    }
5047
5048    #[tokio::test]
5049    async fn test_btree_index_state_reconstruct_and_plugin_cache() {
5050        let tmpdir = TempObjDir::default();
5051        let test_store = Arc::new(LanceIndexStore::new(
5052            Arc::new(ObjectStore::local()),
5053            tmpdir.clone(),
5054            Arc::new(LanceCache::no_cache()),
5055        ));
5056
5057        let stream = gen_batch()
5058            .col("value", array::step::<Int32Type>())
5059            .col("_rowid", array::step::<UInt64Type>())
5060            .into_df_stream(RowCount::from(1000), BatchCount::from(5));
5061        train_btree_index(stream, test_store.as_ref(), 1000, None, None)
5062            .await
5063            .unwrap();
5064
5065        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
5066            .await
5067            .unwrap();
5068
5069        // Round-trip the state through the codec and reconstruct an index from it.
5070        let state = BTreeIndexState {
5071            lookup_batch: index.lookup_batch.clone(),
5072            batch_size: index.batch_size,
5073            ranges_to_files: index.ranges_to_files.clone(),
5074        };
5075        let mut buf = Vec::new();
5076        state.serialize(&mut buf).unwrap();
5077        let restored = BTreeIndexState::deserialize(&bytes::Bytes::from(buf)).unwrap();
5078        let reconstructed = restored
5079            .reconstruct(test_store.clone(), &LanceCache::no_cache(), None)
5080            .unwrap();
5081        assert_eq!(
5082            reconstructed
5083                .as_any()
5084                .downcast_ref::<BTreeIndex>()
5085                .unwrap()
5086                .page_lookup,
5087            index.page_lookup
5088        );
5089
5090        // The plugin's put/get hooks round-trip through a real cache + the codec.
5091        let cache = LanceCache::with_capacity(64 * 1024 * 1024);
5092        let plugin = BTreeIndexPlugin;
5093        plugin.put_in_cache(&cache, index.clone()).await.unwrap();
5094        let from_cache = plugin
5095            .get_from_cache(test_store.clone(), None, &cache)
5096            .await
5097            .unwrap()
5098            .expect("index should be served from the cache");
5099
5100        // Searches against the cached index match the original.
5101        let query = SargableQuery::Range(
5102            std::ops::Bound::Included(ScalarValue::Int32(Some(100))),
5103            std::ops::Bound::Excluded(ScalarValue::Int32(Some(200))),
5104        );
5105        let expected = index.search(&query, &NoOpMetricsCollector).await.unwrap();
5106        let actual = from_cache
5107            .search(&query, &NoOpMetricsCollector)
5108            .await
5109            .unwrap();
5110        assert_eq!(expected, actual);
5111    }
5112
5113    #[test]
5114    fn test_btree_index_state_rejects_invalid_has_ranges_tag() {
5115        // u64 batch_size (any) then a bad has_ranges tag.
5116        let mut buf = Vec::new();
5117        buf.extend_from_slice(&1000u64.to_le_bytes());
5118        buf.push(7u8);
5119        let err = BTreeIndexState::deserialize(&bytes::Bytes::from(buf)).unwrap_err();
5120        let msg = err.to_string();
5121        assert!(
5122            msg.contains("has_ranges") && msg.contains("7"),
5123            "expected error to mention the bad has_ranges tag, got: {msg}"
5124        );
5125    }
5126
5127    #[tokio::test]
5128    async fn test_btree_index_state_reconstruct_applies_frag_reuse_index() {
5129        use crate::frag_reuse::{FragReuseIndex, FragReuseIndexDetails};
5130        use std::collections::HashMap;
5131        use uuid::Uuid;
5132
5133        let tmpdir = TempObjDir::default();
5134        let test_store = Arc::new(LanceIndexStore::new(
5135            Arc::new(ObjectStore::local()),
5136            tmpdir.clone(),
5137            Arc::new(LanceCache::no_cache()),
5138        ));
5139
5140        // value == _rowid for all rows in [0, 1000).
5141        let stream = gen_batch()
5142            .col("value", array::step::<Int32Type>())
5143            .col("_rowid", array::step::<UInt64Type>())
5144            .into_df_stream(RowCount::from(1000), BatchCount::from(1));
5145        train_btree_index(stream, test_store.as_ref(), 1000, None, None)
5146            .await
5147            .unwrap();
5148
5149        let index = BTreeIndex::load(test_store.clone(), None, &LanceCache::no_cache())
5150            .await
5151            .unwrap();
5152        let state = BTreeIndexState {
5153            lookup_batch: index.lookup_batch.clone(),
5154            batch_size: index.batch_size,
5155            ranges_to_files: index.ranges_to_files.clone(),
5156        };
5157
5158        // Remap row 0 -> row 5000 (outside the original [0, 1000) range so no collision).
5159        // Querying for value == 0 should now return row 5000, confirming reconstruct threaded
5160        // the FragReuseIndex through to the rebuilt BTreeIndex.
5161        let frag_reuse_index = Arc::new(FragReuseIndex::new(
5162            Uuid::new_v4(),
5163            vec![HashMap::from([(0u64, Some(5000u64))])],
5164            FragReuseIndexDetails { versions: vec![] },
5165        ));
5166        let reconstructed = state
5167            .reconstruct(
5168                test_store.clone(),
5169                &LanceCache::no_cache(),
5170                Some(frag_reuse_index),
5171            )
5172            .unwrap();
5173
5174        let result = reconstructed
5175            .search(
5176                &SargableQuery::Equals(ScalarValue::Int32(Some(0))),
5177                &NoOpMetricsCollector,
5178            )
5179            .await
5180            .unwrap();
5181        let row_ids: Vec<u64> = match &result {
5182            SearchResult::Exact(set) => set
5183                .true_rows()
5184                .row_addrs()
5185                .unwrap()
5186                .map(u64::from)
5187                .collect(),
5188            other => panic!("expected Exact, got {other:?}"),
5189        };
5190        assert_eq!(
5191            row_ids,
5192            vec![5000],
5193            "frag_reuse_index remap was not applied"
5194        );
5195    }
5196
5197    #[tokio::test]
5198    async fn test_btree_index_state_range_partitioned_plugin_cache_roundtrip() {
5199        // Build a range-partitioned BTree (two range partitions merged into one index) and
5200        // round-trip it through the plugin's cache hooks. This exercises the
5201        // `ranges_to_files = Some` path end-to-end through serialize/deserialize/reconstruct.
5202        let tmpdir = TempObjDir::default();
5203        let store = Arc::new(LanceIndexStore::new(
5204            Arc::new(ObjectStore::local()),
5205            tmpdir.clone(),
5206            Arc::new(LanceCache::no_cache()),
5207        ));
5208
5209        let half = DEFAULT_BTREE_BATCH_SIZE;
5210        let total = (2 * half) as i32;
5211
5212        // Partition 0: values/rowids [0, half).
5213        let part0 = gen_batch()
5214            .col("value", array::step::<Int32Type>())
5215            .col("_rowid", array::step::<UInt64Type>())
5216            .into_df_stream(RowCount::from(half), BatchCount::from(1));
5217        train_btree_index(part0, store.as_ref(), half, None, Some(0u32))
5218            .await
5219            .unwrap();
5220
5221        // Partition 1: values/rowids [half, 2*half).
5222        let values: Vec<i32> = (half as i32..total).collect();
5223        let row_ids: Vec<u64> = (half..total as u64).collect();
5224        let part1 = gen_batch()
5225            .col("value", array::cycle::<Int32Type>(values))
5226            .col("_rowid", array::cycle::<UInt64Type>(row_ids))
5227            .into_df_stream(RowCount::from(half), BatchCount::from(1));
5228        train_btree_index(part1, store.as_ref(), half, None, Some(1u32))
5229            .await
5230            .unwrap();
5231
5232        super::merge_metadata_files(
5233            store.as_ref(),
5234            &[
5235                part_page_data_file_path(0 << 32),
5236                part_page_data_file_path(1 << 32),
5237            ],
5238            &[
5239                part_lookup_file_path(0 << 32),
5240                part_lookup_file_path(1 << 32),
5241            ],
5242            Some(1usize),
5243            noop_progress(),
5244        )
5245        .await
5246        .unwrap();
5247
5248        let index = BTreeIndex::load(store.clone(), None, &LanceCache::no_cache())
5249            .await
5250            .unwrap();
5251        assert!(
5252            index.ranges_to_files.is_some(),
5253            "test setup should produce a range-partitioned index",
5254        );
5255
5256        let cache = LanceCache::with_capacity(64 * 1024 * 1024);
5257        let plugin = BTreeIndexPlugin;
5258        plugin.put_in_cache(&cache, index.clone()).await.unwrap();
5259        let from_cache = plugin
5260            .get_from_cache(store.clone(), None, &cache)
5261            .await
5262            .unwrap()
5263            .expect("index should be served from the cache");
5264
5265        // Search a value from each range partition and confirm both paths agree.
5266        for value in [0i32, total - 1] {
5267            let query = SargableQuery::Equals(ScalarValue::Int32(Some(value)));
5268            let expected = index.search(&query, &NoOpMetricsCollector).await.unwrap();
5269            let actual = from_cache
5270                .search(&query, &NoOpMetricsCollector)
5271                .await
5272                .unwrap();
5273            assert_eq!(expected, actual, "value {value}");
5274        }
5275    }
5276}