Skip to main content

lance_index/vector/v3/
shuffler.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Shuffler is a component that takes a stream of record batches and shuffles them into
5//! the corresponding IVF partitions.
6
7use std::ops::Range;
8use std::sync::atomic::AtomicU64;
9use std::sync::{Arc, Mutex};
10
11use arrow::compute::concat_batches;
12use arrow::datatypes::UInt64Type;
13use arrow::{array::AsArray, compute::sort_to_indices};
14use arrow_array::{RecordBatch, UInt32Array, UInt64Array};
15use arrow_schema::{DataType, Field, Schema};
16use futures::{future::try_join_all, prelude::*};
17use lance_arrow::stream::rechunk_stream_by_size;
18use lance_arrow::{RecordBatchExt, SchemaExt};
19use lance_core::{
20    Error, Result,
21    cache::LanceCache,
22    utils::tokio::{get_num_compute_intensive_cpus, spawn_cpu},
23};
24use lance_encoding::decoder::{DecoderPlugins, FilterExpression};
25use lance_encoding::version::LanceFileVersion;
26use lance_file::reader::{FileReader, FileReaderOptions};
27use lance_file::writer::{FileWriter, FileWriterOptions};
28use lance_io::{
29    ReadBatchParams,
30    object_store::ObjectStore,
31    scheduler::{ScanScheduler, SchedulerConfig},
32    stream::{RecordBatchStream, RecordBatchStreamAdapter},
33    utils::CachedFileSize,
34};
35use object_store::path::Path;
36
37use crate::vector::{LOSS_METADATA_KEY, PART_ID_COLUMN};
38
39#[async_trait::async_trait]
40/// A reader that can read the shuffled partitions.
41pub trait ShuffleReader: Send + Sync {
42    /// Read a partition by partition_id
43    /// will return Ok(None) if partition_size is 0
44    /// check reader.partition_size(partition_id) before calling this function
45    async fn read_partition(
46        &self,
47        partition_id: usize,
48    ) -> Result<Option<Box<dyn RecordBatchStream + Unpin + 'static>>>;
49
50    /// Get the size of the partition by partition_id
51    fn partition_size(&self, partition_id: usize) -> Result<usize>;
52
53    /// Get the total loss,
54    /// if the loss is not available, return None,
55    /// in such case, the caller should sum up the losses from each batch's metadata.
56    /// Must be called after all partitions are read.
57    fn total_loss(&self) -> Option<f64>;
58}
59
60#[async_trait::async_trait]
61/// A shuffler that can shuffle the incoming stream of record batches into IVF partitions.
62/// Returns a IvfShuffleReader that can be used to read the shuffled partitions.
63pub trait Shuffler: Send + Sync {
64    /// Shuffle the incoming stream of record batches into IVF partitions.
65    /// Returns a IvfShuffleReader that can be used to read the shuffled partitions.
66    async fn shuffle(
67        &self,
68        data: Box<dyn RecordBatchStream + Unpin + 'static>,
69    ) -> Result<Box<dyn ShuffleReader>>;
70}
71
72pub struct IvfShuffler {
73    object_store: Arc<ObjectStore>,
74    output_dir: Path,
75    num_partitions: usize,
76    format_version: LanceFileVersion,
77
78    progress: Arc<dyn crate::progress::IndexBuildProgress>,
79}
80
81impl IvfShuffler {
82    pub fn new(output_dir: Path, num_partitions: usize) -> Self {
83        Self {
84            object_store: Arc::new(ObjectStore::local()),
85            output_dir,
86            num_partitions,
87            format_version: LanceFileVersion::V2_0,
88            progress: crate::progress::noop_progress(),
89        }
90    }
91
92    pub fn with_format_version(mut self, format_version: LanceFileVersion) -> Self {
93        self.format_version = format_version;
94        self
95    }
96
97    pub fn with_progress(mut self, progress: Arc<dyn crate::progress::IndexBuildProgress>) -> Self {
98        self.progress = progress;
99        self
100    }
101}
102
103#[async_trait::async_trait]
104impl Shuffler for IvfShuffler {
105    async fn shuffle(
106        &self,
107        data: Box<dyn RecordBatchStream + Unpin + 'static>,
108    ) -> Result<Box<dyn ShuffleReader>> {
109        let num_partitions = self.num_partitions;
110        let mut partition_sizes = vec![0; num_partitions];
111        let schema = data.schema().without_column(PART_ID_COLUMN);
112        let mut writers = stream::iter(0..num_partitions)
113            .map(|partition_id| {
114                let part_path = self.output_dir.child(format!("ivf_{}.lance", partition_id));
115                let spill_path = self.output_dir.child(format!("ivf_{}.spill", partition_id));
116                let object_store = self.object_store.clone();
117                let schema = schema.clone();
118                let format_version = self.format_version;
119                async move {
120                    let writer = object_store.create(&part_path).await?;
121                    let file_writer = FileWriter::try_new(
122                        writer,
123                        lance_core::datatypes::Schema::try_from(&schema)?,
124                        FileWriterOptions {
125                            format_version: Some(format_version),
126                            ..Default::default()
127                        },
128                    )?
129                    .with_page_metadata_spill(object_store.clone(), spill_path);
130                    Result::Ok(file_writer)
131                }
132            })
133            .buffered(self.object_store.io_parallelism())
134            .try_collect::<Vec<_>>()
135            .await?;
136        let mut parallel_sort_stream = data
137            .map(|batch| {
138                spawn_cpu(move || {
139                    let batch = batch?;
140
141                    let loss = batch
142                        .metadata()
143                        .get(LOSS_METADATA_KEY)
144                        .map(|s| s.parse::<f64>().unwrap_or_default())
145                        .unwrap_or_default();
146
147                    let part_ids: &UInt32Array = batch[PART_ID_COLUMN].as_primitive();
148
149                    let indices = sort_to_indices(&part_ids, None, None)?;
150                    let batch = batch.take(&indices)?;
151
152                    let part_ids: &UInt32Array = batch[PART_ID_COLUMN].as_primitive();
153                    let batch = batch.drop_column(PART_ID_COLUMN)?;
154
155                    let mut partition_buffers = vec![Vec::new(); num_partitions];
156
157                    let mut start = 0;
158                    while start < batch.num_rows() {
159                        let part_id: u32 = part_ids.value(start);
160                        let mut end = start + 1;
161                        while end < batch.num_rows() && part_ids.value(end) == part_id {
162                            end += 1;
163                        }
164
165                        let part_batches = &mut partition_buffers[part_id as usize];
166                        part_batches.push(batch.slice(start, end - start));
167                        start = end;
168                    }
169
170                    Ok::<(Vec<Vec<RecordBatch>>, f64), Error>((partition_buffers, loss))
171                })
172            })
173            .buffered(get_num_compute_intensive_cpus());
174
175        let mut total_loss = 0.0;
176        let mut num_rows = 0u64;
177        while let Some(shuffled) = parallel_sort_stream.next().await {
178            let (shuffled, loss) = shuffled?;
179            total_loss += loss;
180
181            let mut futs = Vec::new();
182            for (part_id, (writer, batches)) in writers.iter_mut().zip(shuffled.iter()).enumerate()
183            {
184                if !batches.is_empty() {
185                    let rows = batches.iter().map(|b| b.num_rows()).sum::<usize>();
186                    partition_sizes[part_id] += rows;
187                    num_rows += rows as u64;
188                    futs.push(writer.write_batches(batches.iter()));
189                }
190            }
191            try_join_all(futs).await?;
192
193            self.progress.stage_progress("shuffle", num_rows).await?;
194        }
195
196        // finish all writers
197        for writer in writers.iter_mut() {
198            writer.finish().await?;
199        }
200
201        Ok(Box::new(IvfShufflerReader::new(
202            self.object_store.clone(),
203            self.output_dir.clone(),
204            partition_sizes,
205            total_loss,
206        )))
207    }
208}
209
210pub struct IvfShufflerReader {
211    scheduler: Arc<ScanScheduler>,
212    output_dir: Path,
213    partition_sizes: Vec<usize>,
214    loss: f64,
215}
216
217impl IvfShufflerReader {
218    pub fn new(
219        object_store: Arc<ObjectStore>,
220        output_dir: Path,
221        partition_sizes: Vec<usize>,
222        loss: f64,
223    ) -> Self {
224        let scheduler_config = SchedulerConfig::max_bandwidth(&object_store);
225        let scheduler = ScanScheduler::new(object_store, scheduler_config);
226        Self {
227            scheduler,
228            output_dir,
229            partition_sizes,
230            loss,
231        }
232    }
233}
234
235#[async_trait::async_trait]
236impl ShuffleReader for IvfShufflerReader {
237    async fn read_partition(
238        &self,
239        partition_id: usize,
240    ) -> Result<Option<Box<dyn RecordBatchStream + Unpin + 'static>>> {
241        if partition_id >= self.partition_sizes.len() {
242            return Ok(None);
243        }
244
245        let partition_path = self.output_dir.child(format!("ivf_{}.lance", partition_id));
246
247        let reader = FileReader::try_open(
248            self.scheduler
249                .open_file(&partition_path, &CachedFileSize::unknown())
250                .await?,
251            None,
252            Arc::<DecoderPlugins>::default(),
253            &LanceCache::no_cache(),
254            FileReaderOptions::default(),
255        )
256        .await?;
257        let schema: Schema = reader.schema().as_ref().into();
258        Ok(Some(Box::new(RecordBatchStreamAdapter::new(
259            Arc::new(schema),
260            reader.read_stream(
261                lance_io::ReadBatchParams::RangeFull,
262                u32::MAX,
263                16,
264                FilterExpression::no_filter(),
265            )?,
266        ))))
267    }
268
269    fn partition_size(&self, partition_id: usize) -> Result<usize> {
270        Ok(self.partition_sizes.get(partition_id).copied().unwrap_or(0))
271    }
272
273    fn total_loss(&self) -> Option<f64> {
274        Some(self.loss)
275    }
276}
277
278pub struct EmptyReader;
279
280#[async_trait::async_trait]
281impl ShuffleReader for EmptyReader {
282    async fn read_partition(
283        &self,
284        _partition_id: usize,
285    ) -> Result<Option<Box<dyn RecordBatchStream + Unpin + 'static>>> {
286        Ok(None)
287    }
288
289    fn partition_size(&self, _partition_id: usize) -> Result<usize> {
290        Ok(0)
291    }
292
293    fn total_loss(&self) -> Option<f64> {
294        None
295    }
296}
297
298/// Create an IVF shuffler. Uses [`TwoFileShuffler`] by default, which writes
299/// all data to just two files (data + offsets) instead of one file per partition.
300/// Set `LANCE_LEGACY_SHUFFLER=1` to fall back to [`IvfShuffler`], which opens
301/// one file per partition.
302///
303/// An optional `progress` callback can be provided to receive shuffle progress
304/// updates.
305pub fn create_ivf_shuffler(
306    output_dir: Path,
307    num_partitions: usize,
308    format_version: LanceFileVersion,
309    progress: Option<Arc<dyn crate::progress::IndexBuildProgress>>,
310) -> Box<dyn Shuffler> {
311    let use_legacy = std::env::var("LANCE_LEGACY_SHUFFLER")
312        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
313        .unwrap_or(false);
314    if use_legacy {
315        let mut shuffler =
316            IvfShuffler::new(output_dir, num_partitions).with_format_version(format_version);
317        if let Some(progress) = progress {
318            shuffler = shuffler.with_progress(progress);
319        }
320        Box::new(shuffler)
321    } else {
322        let mut shuffler = TwoFileShuffler::new(output_dir, num_partitions);
323        if let Some(progress) = progress {
324            shuffler = shuffler.with_progress(progress);
325        }
326        Box::new(shuffler)
327    }
328}
329
330const DEFAULT_SHUFFLE_BATCH_BYTES: usize = 128 * 1024 * 1024;
331
332/// Limit of how much transformed data we accumulate before spilling to disk.
333///
334/// A larger value will use more RAM but require less random access during the
335/// read phase.
336///
337/// This default is likely to be fine for most use cases.
338fn shuffle_batch_bytes() -> usize {
339    let batch_size = std::env::var("LANCE_SHUFFLE_BATCH_BYTES")
340        .ok()
341        .and_then(|s| s.parse().ok())
342        .unwrap_or(DEFAULT_SHUFFLE_BATCH_BYTES);
343    if batch_size == 0 {
344        log::warn!(
345            "LANCE_SHUFFLE_BATCH_BYTES is 0, using default of {}",
346            DEFAULT_SHUFFLE_BATCH_BYTES
347        );
348        DEFAULT_SHUFFLE_BATCH_BYTES
349    } else {
350        batch_size
351    }
352}
353
354/// A shuffler that writes all data to just two files (data + offsets) instead
355/// of one file per partition. This avoids hitting OS file descriptor limits
356/// when there are many partitions.
357///
358/// First we accumulate data in memory until we reach the batch size limit.
359/// Then we sort the data by partition ID and compute an offset per partition.
360/// Then we write the data to a data file and the offsets to an offsets file.
361///
362/// To read the data back, we read every Nth value from the offsets file to get
363/// the start and end of each partition.
364///
365/// Then we read those ranges from the data file.
366pub struct TwoFileShuffler {
367    object_store: Arc<ObjectStore>,
368    output_dir: Path,
369    num_partitions: usize,
370    batch_size_bytes: usize,
371
372    progress: Arc<dyn crate::progress::IndexBuildProgress>,
373}
374
375impl TwoFileShuffler {
376    pub fn new(output_dir: Path, num_partitions: usize) -> Self {
377        Self {
378            object_store: Arc::new(ObjectStore::local()),
379            output_dir,
380            num_partitions,
381            batch_size_bytes: shuffle_batch_bytes(),
382            progress: crate::progress::noop_progress(),
383        }
384    }
385
386    pub fn with_progress(mut self, progress: Arc<dyn crate::progress::IndexBuildProgress>) -> Self {
387        self.progress = progress;
388        self
389    }
390
391    #[cfg(test)]
392    fn with_batch_size_bytes(mut self, batch_size_bytes: usize) -> Self {
393        self.batch_size_bytes = batch_size_bytes;
394        self
395    }
396}
397
398#[async_trait::async_trait]
399impl Shuffler for TwoFileShuffler {
400    async fn shuffle(
401        &self,
402        data: Box<dyn RecordBatchStream + Unpin + 'static>,
403    ) -> Result<Box<dyn ShuffleReader>> {
404        let num_partitions = self.num_partitions;
405        let full_schema = Arc::new(data.schema().as_ref().clone());
406        // No need to write partition ids since we can infer this
407        let schema = data.schema().without_column(PART_ID_COLUMN);
408        let offsets_schema = Arc::new(Schema::new(vec![Field::new(
409            "offset",
410            DataType::UInt64,
411            false,
412        )]));
413        let batch_size_bytes = self.batch_size_bytes;
414
415        // Extract loss from batch metadata before rechunking (concat_batches drops metadata)
416        let total_loss = Arc::new(Mutex::new(0.0f64));
417        let loss_ref = total_loss.clone();
418        let loss_stream = data.map(move |result| {
419            result.inspect(|batch| {
420                let loss = batch
421                    .metadata()
422                    .get(LOSS_METADATA_KEY)
423                    .and_then(|s| s.parse::<f64>().ok())
424                    .unwrap_or(0.0);
425                *loss_ref.lock().unwrap() += loss;
426            })
427        });
428
429        // Rechunk to target batch size
430        let rechunked = rechunk_stream_by_size(
431            loss_stream,
432            full_schema,
433            batch_size_bytes,
434            batch_size_bytes * 2,
435        );
436
437        // Create data file writer
438        let data_path = self.output_dir.child("shuffle_data.lance");
439        let spill_path = self.output_dir.child("shuffle_data.spill");
440        let writer = self.object_store.create(&data_path).await?;
441        let mut file_writer = FileWriter::try_new(
442            writer,
443            lance_core::datatypes::Schema::try_from(&schema)?,
444            Default::default(),
445        )?
446        .with_page_metadata_spill(self.object_store.clone(), spill_path);
447
448        // Create offsets file writer
449        let offsets_path = self.output_dir.child("shuffle_offsets.lance");
450        let spill_path = self.output_dir.child("shuffle_offsets.spill");
451        let writer = self.object_store.create(&offsets_path).await?;
452        let mut offsets_writer = FileWriter::try_new(
453            writer,
454            lance_core::datatypes::Schema::try_from(offsets_schema.as_ref())?,
455            Default::default(),
456        )?
457        .with_page_metadata_spill(self.object_store.clone(), spill_path);
458
459        let num_batches = Arc::new(AtomicU64::new(0));
460        let num_batches_ref = num_batches.clone();
461        let mut partition_counts: Vec<u64> = vec![0; num_partitions];
462        let mut global_row_count: u64 = 0;
463        let mut rows_processed: u64 = 0;
464
465        let mut rechunked = std::pin::pin!(rechunked);
466        while let Some(batch) = rechunked.next().await {
467            num_batches_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
468            let batch = batch?;
469            let np = num_partitions;
470            let num_rows = batch.num_rows() as u64;
471
472            // Sort by partition ID and compute offsets on CPU
473            let (sorted_batch, batch_offsets) = spawn_cpu(move || {
474                let part_ids: &UInt32Array = batch[PART_ID_COLUMN].as_primitive();
475                let indices = sort_to_indices(part_ids, None, None)?;
476                let batch = batch.take(&indices)?;
477
478                let part_ids: &UInt32Array = batch[PART_ID_COLUMN].as_primitive();
479                let batch = batch.drop_column(PART_ID_COLUMN)?;
480
481                // Count rows per partition by scanning sorted part IDs
482                let mut partition_counts = vec![0u64; np];
483                for i in 0..part_ids.len() {
484                    let pid = part_ids.value(i) as usize;
485                    if pid < np {
486                        partition_counts[pid] += 1;
487                    } else {
488                        log::warn!("Partition ID {} is out of range [0, {})", pid, np);
489                    }
490                }
491
492                // Build cumulative offsets (end positions) for this batch
493                let mut batch_offsets = Vec::with_capacity(np);
494                let mut running = 0u64;
495                for count in &partition_counts {
496                    running += count;
497                    batch_offsets.push(running);
498                }
499
500                Ok::<(RecordBatch, Vec<u64>), Error>((batch, batch_offsets))
501            })
502            .await?;
503
504            // Write sorted batch to data file
505            file_writer.write_batch(&sorted_batch).await?;
506
507            // Record offsets adjusted by global row count
508            let mut adjusted_offsets = Vec::with_capacity(batch_offsets.len());
509            let mut last_offset = 0;
510            for (idx, offset) in batch_offsets.iter().enumerate() {
511                adjusted_offsets.push(global_row_count + offset);
512                partition_counts[idx] += offset - last_offset;
513                last_offset = *offset;
514            }
515            global_row_count += sorted_batch.num_rows() as u64;
516
517            // Write offsets to offsets file
518            let offsets_batch = RecordBatch::try_new(
519                offsets_schema.clone(),
520                vec![Arc::new(UInt64Array::from(adjusted_offsets))],
521            )?;
522            offsets_writer.write_batch(&offsets_batch).await?;
523
524            rows_processed += num_rows;
525            self.progress
526                .stage_progress("shuffle", rows_processed)
527                .await?;
528        }
529
530        // Finish files
531        file_writer.finish().await?;
532        offsets_writer.finish().await?;
533
534        let num_batches = num_batches.load(std::sync::atomic::Ordering::Relaxed);
535
536        let total_loss_val = *total_loss.lock().unwrap();
537
538        TwoFileShuffleReader::try_new(
539            self.object_store.clone(),
540            self.output_dir.clone(),
541            num_partitions,
542            num_batches,
543            partition_counts,
544            total_loss_val,
545        )
546        .await
547    }
548}
549
550pub struct TwoFileShuffleReader {
551    _scheduler: Arc<ScanScheduler>,
552    file_reader: FileReader,
553    offsets_reader: FileReader,
554    num_partitions: usize,
555    num_batches: u64,
556    partition_counts: Vec<u64>,
557    total_loss: f64,
558}
559
560impl TwoFileShuffleReader {
561    async fn try_new(
562        object_store: Arc<ObjectStore>,
563        output_dir: Path,
564        num_partitions: usize,
565        num_batches: u64,
566        partition_counts: Vec<u64>,
567        total_loss: f64,
568    ) -> Result<Box<dyn ShuffleReader>> {
569        if num_batches == 0 {
570            return Ok(Box::new(EmptyReader));
571        }
572
573        let scheduler_config = SchedulerConfig::max_bandwidth(&object_store);
574        let scheduler = ScanScheduler::new(object_store, scheduler_config);
575
576        let data_path = output_dir.child("shuffle_data.lance");
577        let file_reader = FileReader::try_open(
578            scheduler
579                .open_file(&data_path, &CachedFileSize::unknown())
580                .await?,
581            None,
582            Arc::<DecoderPlugins>::default(),
583            &LanceCache::no_cache(),
584            FileReaderOptions::default(),
585        )
586        .await?;
587
588        let offsets_path = output_dir.child("shuffle_offsets.lance");
589        let offsets_reader = FileReader::try_open(
590            scheduler
591                .open_file(&offsets_path, &CachedFileSize::unknown())
592                .await?,
593            None,
594            Arc::<DecoderPlugins>::default(),
595            &LanceCache::no_cache(),
596            FileReaderOptions::default(),
597        )
598        .await?;
599
600        Ok(Box::new(Self {
601            _scheduler: scheduler,
602            file_reader,
603            offsets_reader,
604            num_partitions,
605            num_batches,
606            partition_counts,
607            total_loss,
608        }))
609    }
610
611    async fn partition_ranges(&self, partition_id: usize) -> Result<Vec<Range<u64>>> {
612        let mut positions = Vec::with_capacity(self.num_batches as usize * 2);
613        for batch_idx in 0..self.num_batches {
614            let end_pos = u32::try_from(batch_idx as usize * self.num_partitions + partition_id)
615                .map_err(|_| Error::invalid_input("There are more than 2^32 partition offsets in the spill file.  Need to support 64-bit take"))?;
616            if end_pos != 0 {
617                positions.push(end_pos - 1);
618            }
619            positions.push(end_pos);
620        }
621        let positions = UInt32Array::from(positions);
622        let num_positions = positions.len() as u32;
623        let offsets_stream = self.offsets_reader.read_stream(
624            ReadBatchParams::Indices(positions),
625            num_positions,
626            1,
627            FilterExpression::no_filter(),
628        )?;
629        let schema = offsets_stream.schema().clone();
630        let offsets = offsets_stream.try_collect::<Vec<_>>().await?;
631        let offsets = if offsets.is_empty() {
632            // We should not hit this path if there is no batches
633            unreachable!()
634        } else if offsets.len() == 1 {
635            offsets.into_iter().next().unwrap()
636        } else {
637            concat_batches(&schema, &offsets)?
638        };
639
640        let offsets = offsets.column(0).as_primitive::<UInt64Type>();
641        let mut offsets_iter = offsets.values().iter().copied();
642
643        let mut ranges = Vec::with_capacity(self.num_batches as usize);
644        for batch_idx in 0..self.num_batches {
645            if batch_idx == 0 && partition_id == 0 {
646                // Implicit 0 for start-of-file
647                ranges.push(0..offsets_iter.next().unwrap());
648            } else {
649                ranges.push(offsets_iter.next().unwrap()..offsets_iter.next().unwrap());
650            }
651        }
652        Ok(ranges)
653    }
654}
655
656#[async_trait::async_trait]
657impl ShuffleReader for TwoFileShuffleReader {
658    async fn read_partition(
659        &self,
660        partition_id: usize,
661    ) -> Result<Option<Box<dyn RecordBatchStream + Unpin + 'static>>> {
662        if partition_id >= self.num_partitions {
663            return Ok(None);
664        }
665        if self.partition_counts[partition_id] == 0 {
666            return Ok(None);
667        }
668
669        let ranges = self.partition_ranges(partition_id).await?;
670        if ranges.is_empty() {
671            return Ok(None);
672        }
673
674        let schema: Schema = self.file_reader.schema().as_ref().into();
675        Ok(Some(Box::new(RecordBatchStreamAdapter::new(
676            Arc::new(schema),
677            self.file_reader.read_stream(
678                ReadBatchParams::Ranges(ranges.into()),
679                u32::MAX,
680                16,
681                FilterExpression::no_filter(),
682            )?,
683        ))))
684    }
685
686    fn partition_size(&self, partition_id: usize) -> Result<usize> {
687        Ok(self
688            .partition_counts
689            .get(partition_id)
690            .copied()
691            .unwrap_or(0) as usize)
692    }
693
694    fn total_loss(&self) -> Option<f64> {
695        Some(self.total_loss)
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    use arrow_array::{Int32Array, RecordBatch, UInt32Array};
704    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
705    use futures::stream;
706    use lance_arrow::RecordBatchExt;
707    use lance_core::utils::tempfile::TempStrDir;
708    use lance_io::stream::RecordBatchStreamAdapter;
709
710    use crate::vector::{LOSS_METADATA_KEY, PART_ID_COLUMN};
711
712    /// Create a test batch with partition IDs, an int column, and optional loss metadata.
713    fn make_batch(part_ids: &[u32], values: &[i32], loss: Option<f64>) -> RecordBatch {
714        let schema = Arc::new(ArrowSchema::new(vec![
715            Field::new(PART_ID_COLUMN, DataType::UInt32, false),
716            Field::new("val", DataType::Int32, false),
717        ]));
718        let batch = RecordBatch::try_new(
719            schema,
720            vec![
721                Arc::new(UInt32Array::from(part_ids.to_vec())),
722                Arc::new(Int32Array::from(values.to_vec())),
723            ],
724        )
725        .unwrap();
726        if let Some(loss_val) = loss {
727            batch
728                .add_metadata(LOSS_METADATA_KEY.to_owned(), loss_val.to_string())
729                .unwrap()
730        } else {
731            batch
732        }
733    }
734
735    fn batches_to_stream(
736        batches: Vec<RecordBatch>,
737    ) -> Box<dyn RecordBatchStream + Unpin + 'static> {
738        let schema = batches[0].schema();
739        let stream = stream::iter(batches.into_iter().map(Ok));
740        Box::new(RecordBatchStreamAdapter::new(schema, stream))
741    }
742
743    /// Collect all rows from a partition into a single RecordBatch.
744    async fn collect_partition(
745        reader: &dyn ShuffleReader,
746        partition_id: usize,
747    ) -> Option<RecordBatch> {
748        let stream = reader.read_partition(partition_id).await.unwrap()?;
749        let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
750        if batches.is_empty() {
751            return None;
752        }
753        Some(arrow::compute::concat_batches(&batches[0].schema(), &batches).unwrap())
754    }
755
756    #[tokio::test]
757    async fn test_two_file_shuffler_round_trip() {
758        let dir = TempStrDir::default();
759        let output_dir = Path::from(dir.as_ref());
760        let num_partitions = 3;
761
762        // Partition 0: rows with values 10, 40
763        // Partition 1: rows with values 20, 50
764        // Partition 2: rows with values 30
765        let batch = make_batch(&[0, 1, 2, 0, 1], &[10, 20, 30, 40, 50], None);
766
767        let shuffler = TwoFileShuffler::new(output_dir, num_partitions);
768        let stream = batches_to_stream(vec![batch]);
769        let reader = shuffler.shuffle(stream).await.unwrap();
770
771        // Verify partition sizes
772        assert_eq!(reader.partition_size(0).unwrap(), 2);
773        assert_eq!(reader.partition_size(1).unwrap(), 2);
774        assert_eq!(reader.partition_size(2).unwrap(), 1);
775
776        // Verify partition 0 data
777        let p0 = collect_partition(reader.as_ref(), 0).await.unwrap();
778        let vals: &Int32Array = p0.column_by_name("val").unwrap().as_primitive();
779        let mut v: Vec<i32> = vals.iter().map(|x| x.unwrap()).collect();
780        v.sort();
781        assert_eq!(v, vec![10, 40]);
782
783        // Verify partition 1 data
784        let p1 = collect_partition(reader.as_ref(), 1).await.unwrap();
785        let vals: &Int32Array = p1.column_by_name("val").unwrap().as_primitive();
786        let mut v: Vec<i32> = vals.iter().map(|x| x.unwrap()).collect();
787        v.sort();
788        assert_eq!(v, vec![20, 50]);
789
790        // Verify partition 2 data
791        let p2 = collect_partition(reader.as_ref(), 2).await.unwrap();
792        let vals: &Int32Array = p2.column_by_name("val").unwrap().as_primitive();
793        let v: Vec<i32> = vals.iter().map(|x| x.unwrap()).collect();
794        assert_eq!(v, vec![30]);
795
796        // Out of range partition returns None
797        assert!(reader.read_partition(3).await.unwrap().is_none());
798    }
799
800    #[tokio::test]
801    async fn test_two_file_shuffler_empty_partitions() {
802        let dir = TempStrDir::default();
803        let output_dir = Path::from(dir.as_ref());
804        let num_partitions = 5;
805
806        // Only use partitions 0 and 3, leaving 1, 2, 4 empty
807        let batch = make_batch(&[0, 3, 0, 3], &[10, 20, 30, 40], None);
808
809        let shuffler = TwoFileShuffler::new(output_dir, num_partitions);
810        let stream = batches_to_stream(vec![batch]);
811        let reader = shuffler.shuffle(stream).await.unwrap();
812
813        assert_eq!(reader.partition_size(0).unwrap(), 2);
814        assert_eq!(reader.partition_size(1).unwrap(), 0);
815        assert_eq!(reader.partition_size(2).unwrap(), 0);
816        assert_eq!(reader.partition_size(3).unwrap(), 2);
817        assert_eq!(reader.partition_size(4).unwrap(), 0);
818
819        assert!(reader.read_partition(1).await.unwrap().is_none());
820        assert!(reader.read_partition(2).await.unwrap().is_none());
821        assert!(reader.read_partition(4).await.unwrap().is_none());
822
823        let p0 = collect_partition(reader.as_ref(), 0).await.unwrap();
824        assert_eq!(p0.num_rows(), 2);
825        let p3 = collect_partition(reader.as_ref(), 3).await.unwrap();
826        assert_eq!(p3.num_rows(), 2);
827    }
828
829    #[tokio::test]
830    async fn test_two_file_shuffler_loss_tracking() {
831        let dir = TempStrDir::default();
832        let output_dir = Path::from(dir.as_ref());
833        let num_partitions = 2;
834
835        let batch1 = make_batch(&[0, 1], &[10, 20], Some(1.5));
836        let batch2 = make_batch(&[0, 1], &[30, 40], Some(2.5));
837        let batch3 = make_batch(&[0], &[50], Some(0.25));
838
839        let shuffler = TwoFileShuffler::new(output_dir, num_partitions);
840        let stream = batches_to_stream(vec![batch1, batch2, batch3]);
841        let reader = shuffler.shuffle(stream).await.unwrap();
842
843        let loss = reader.total_loss().unwrap();
844        assert!((loss - 4.25).abs() < 1e-10, "expected 4.25, got {}", loss);
845    }
846
847    #[tokio::test]
848    async fn test_two_file_shuffler_single_batch() {
849        let dir = TempStrDir::default();
850        let output_dir = Path::from(dir.as_ref());
851        let num_partitions = 2;
852
853        let batch = make_batch(&[1, 0], &[100, 200], Some(3.0));
854
855        let shuffler = TwoFileShuffler::new(output_dir, num_partitions);
856        let stream = batches_to_stream(vec![batch]);
857        let reader = shuffler.shuffle(stream).await.unwrap();
858
859        assert_eq!(reader.partition_size(0).unwrap(), 1);
860        assert_eq!(reader.partition_size(1).unwrap(), 1);
861
862        let p0 = collect_partition(reader.as_ref(), 0).await.unwrap();
863        let vals: &Int32Array = p0.column_by_name("val").unwrap().as_primitive();
864        assert_eq!(vals.value(0), 200);
865
866        let p1 = collect_partition(reader.as_ref(), 1).await.unwrap();
867        let vals: &Int32Array = p1.column_by_name("val").unwrap().as_primitive();
868        assert_eq!(vals.value(0), 100);
869
870        assert!((reader.total_loss().unwrap() - 3.0).abs() < 1e-10);
871    }
872
873    #[tokio::test]
874    async fn test_two_file_shuffler_multiple_batches() {
875        let dir = TempStrDir::default();
876        let output_dir = Path::from(dir.as_ref());
877        let num_partitions = 3;
878
879        // Use a very small batch size to force multiple write batches
880        // Each i32 is 4 bytes, each u32 is 4 bytes, so ~8 bytes/row.
881        // With a small batch_size_bytes, we get multiple rechunked batches.
882        let batch1 = make_batch(&[0, 1, 2], &[10, 20, 30], Some(1.0));
883        let batch2 = make_batch(&[2, 0, 1], &[40, 50, 60], Some(2.0));
884        let batch3 = make_batch(&[1, 2, 0], &[70, 80, 90], Some(3.0));
885
886        let shuffler = TwoFileShuffler::new(output_dir, num_partitions)
887            // Set very small batch size to force multiple batches
888            .with_batch_size_bytes(16);
889        let stream = batches_to_stream(vec![batch1, batch2, batch3]);
890        let reader = shuffler.shuffle(stream).await.unwrap();
891
892        // Partition 0 should have values: 10, 50, 90
893        assert_eq!(reader.partition_size(0).unwrap(), 3);
894        let p0 = collect_partition(reader.as_ref(), 0).await.unwrap();
895        let vals: &Int32Array = p0.column_by_name("val").unwrap().as_primitive();
896        let mut v: Vec<i32> = vals.iter().map(|x| x.unwrap()).collect();
897        v.sort();
898        assert_eq!(v, vec![10, 50, 90]);
899
900        // Partition 1 should have values: 20, 60, 70
901        assert_eq!(reader.partition_size(1).unwrap(), 3);
902        let p1 = collect_partition(reader.as_ref(), 1).await.unwrap();
903        let vals: &Int32Array = p1.column_by_name("val").unwrap().as_primitive();
904        let mut v: Vec<i32> = vals.iter().map(|x| x.unwrap()).collect();
905        v.sort();
906        assert_eq!(v, vec![20, 60, 70]);
907
908        // Partition 2 should have values: 30, 40, 80
909        assert_eq!(reader.partition_size(2).unwrap(), 3);
910        let p2 = collect_partition(reader.as_ref(), 2).await.unwrap();
911        let vals: &Int32Array = p2.column_by_name("val").unwrap().as_primitive();
912        let mut v: Vec<i32> = vals.iter().map(|x| x.unwrap()).collect();
913        v.sort();
914        assert_eq!(v, vec![30, 40, 80]);
915
916        assert!((reader.total_loss().unwrap() - 6.0).abs() < 1e-10);
917    }
918}