Skip to main content

lance_file/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use core::panic;
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::sync::atomic::AtomicBool;
8
9use arrow_array::{ArrayRef, RecordBatch};
10
11use arrow_data::ArrayData;
12use bytes::{Buf, BufMut, Bytes, BytesMut};
13use futures::StreamExt;
14use futures::stream::FuturesOrdered;
15use lance_core::datatypes::{Field, Schema as LanceSchema};
16use lance_core::utils::bit::pad_bytes;
17use lance_core::{Error, Result};
18use lance_encoding::compression_config::CompressionParams;
19use lance_encoding::decoder::PageEncoding;
20use lance_encoding::encoder::{
21    BatchEncoder, EncodeTask, EncodedBatch, EncodedPage, EncodingOptions, FieldEncoder,
22    FieldEncodingStrategy, OutOfLineBuffers,
23};
24use lance_encoding::repdef::RepDefBuilder;
25use lance_io::object_store::ObjectStore;
26use lance_io::traits::Writer;
27use log::{debug, warn};
28use object_store::path::Path;
29use prost::Message;
30use prost_types::Any;
31use tokio::io::AsyncWrite;
32use tokio::io::AsyncWriteExt;
33use tracing::instrument;
34
35use crate::datatypes::FieldsWithMeta;
36use crate::format::MAGIC;
37use crate::format::pb;
38use crate::format::pbfile;
39use crate::format::pbfile::DirectEncoding;
40use crate::version::{ConcreteFileVersion, LanceFileVersion};
41use crate::versions;
42
43pub(crate) mod structural;
44
45/// Pages buffers are aligned to 64 bytes
46pub(crate) const PAGE_BUFFER_ALIGNMENT: usize = 64;
47const PAD_BUFFER: [u8; PAGE_BUFFER_ALIGNMENT] = [72; PAGE_BUFFER_ALIGNMENT];
48// In 2.1+, we split large pages on read instead of write to avoid empty pages
49// and small pages issues. However, we keep the write-time limit at 32MB to avoid
50// potential regressions in 2.0 format readers.
51//
52// This limit is not applied in the 2.1 writer
53const MAX_PAGE_BYTES: usize = 32 * 1024 * 1024;
54pub(crate) const ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES: &str = "LANCE_FILE_WRITER_MAX_PAGE_BYTES";
55
56#[cfg(test)]
57fn encoding_strategy_with_params(
58    version: LanceFileVersion,
59    params: CompressionParams,
60) -> Result<Arc<dyn FieldEncodingStrategy>> {
61    match version.resolve() {
62        LanceFileVersion::Legacy | LanceFileVersion::V2_0 => Err(Error::invalid_input(
63            "Compression parameters are only supported in Lance file version 2.1 and later",
64        )),
65        LanceFileVersion::V2_1 => Ok(versions::v2_1::encoding_strategy(params)),
66        LanceFileVersion::V2_2 => Ok(versions::v2_2::encoding_strategy(params)),
67        LanceFileVersion::V2_3 => Ok(versions::v2_3::encoding_strategy(params)),
68        LanceFileVersion::Stable | LanceFileVersion::Next => {
69            unreachable!("resolved file-version selector must be exact")
70        }
71    }
72}
73
74fn encoding_strategy(version: LanceFileVersion) -> Arc<dyn FieldEncodingStrategy> {
75    match version.resolve() {
76        LanceFileVersion::Legacy => {
77            panic!("legacy v1 files require versions::v1::writer::FileWriter")
78        }
79        LanceFileVersion::V2_0 => versions::v2_0::encoding_strategy(),
80        LanceFileVersion::V2_1 => versions::v2_1::encoding_strategy(CompressionParams::default()),
81        LanceFileVersion::V2_2 => versions::v2_2::encoding_strategy(CompressionParams::default()),
82        LanceFileVersion::V2_3 => versions::v2_3::encoding_strategy(CompressionParams::default()),
83        LanceFileVersion::Stable | LanceFileVersion::Next => {
84            unreachable!("resolved file-version selector must be exact")
85        }
86    }
87}
88
89/// Summary of a completed Lance file write.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct FileWriteSummary {
92    /// The number of rows written to the file.
93    pub num_rows: u64,
94    /// The final size of the file in bytes.
95    pub size_bytes: u64,
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct FileWriterOptions {
100    /// How many bytes to use for buffering column data
101    ///
102    /// When data comes in small batches the writer will buffer column data so that
103    /// larger pages can be created.  This value will be divided evenly across all of the
104    /// columns.  Generally you want this to be at least large enough to match your
105    /// filesystem's ideal read size per column.
106    ///
107    /// In some cases you might want this value to be even larger if you have highly
108    /// compressible data.  However, if this is too large, then the writer could require
109    /// a lot of memory and write performance may suffer if the CPU-expensive encoding
110    /// falls behind and can't be interleaved with the I/O expensive flushing.
111    ///
112    /// The default will use 8MiB per column which should be reasonable for most cases.
113    // TODO: Do we need to be able to set this on a per-column basis?
114    pub data_cache_bytes: Option<u64>,
115    /// A hint to indicate the max size of a page
116    ///
117    /// This hint can't always be respected.  A single value could be larger than this value
118    /// and we never slice single values.  In addition, there are some cases where it can be
119    /// difficult to know size up-front and so we might not be able to respect this value.
120    pub max_page_bytes: Option<u64>,
121    /// The file writer buffers columns until enough data has arrived to flush a page
122    /// to disk.
123    ///
124    /// Some columns with small data types may not flush very often.  These arrays can
125    /// stick around for a long time.  These arrays might also be keeping larger data
126    /// structures alive.  By default, the writer will make a deep copy of this array
127    /// to avoid any potential memory leaks.  However, this can be disabled for a
128    /// (probably minor) performance boost if you are sure that arrays are not keeping
129    /// any sibling structures alive (this typically means the array was allocated in
130    /// the same language / runtime as the writer)
131    ///
132    /// Do not enable this if your data is arriving from the C data interface.
133    /// Data typically arrives one "batch" at a time (encoded in the C data interface
134    /// as a struct array).  Each array in that batch keeps the entire batch alive.
135    /// This means a small boolean array (which we will buffer in memory for quite a
136    /// while) might keep a much larger record batch around in memory (even though most
137    /// of that batch's data has been written to disk)
138    pub keep_original_array: Option<bool>,
139    pub encoding_strategy: Option<Arc<dyn FieldEncodingStrategy>>,
140    /// The format version to use when writing the file
141    ///
142    /// This controls which encodings will be used when encoding the data.  Newer
143    /// versions may have more efficient encodings.  However, newer format versions will
144    /// require more up-to-date readers to read the data.
145    pub format_version: Option<LanceFileVersion>,
146}
147
148// Total in-memory budget for buffering serialized page metadata before flushing
149// to the spill file. Divided evenly across columns (with a floor of 64 bytes).
150const DEFAULT_SPILL_BUFFER_LIMIT: usize = 256 * 1024;
151
152/// Spills serialized page metadata to a temporary file to bound memory usage.
153///
154/// The spill file is an unstructured sequence of "chunks". Each chunk is a
155/// contiguous run of length-delimited protobuf `Page` messages belonging to a
156/// single column. Chunks from different columns are interleaved in the order
157/// they are flushed (i.e. whenever a column's in-memory buffer exceeds
158/// `per_column_limit`). The `column_chunks` index records the (offset, length)
159/// of every chunk so each column's pages can be read back and reassembled in
160/// order.
161struct PageMetadataSpill {
162    writer: Box<dyn Writer>,
163    object_store: Arc<ObjectStore>,
164    path: Path,
165    /// Current write position in the spill file.
166    position: u64,
167    /// Per-column buffer of serialized (length-delimited protobuf) page metadata
168    /// that has not yet been flushed to the spill file.
169    column_buffers: Vec<Vec<u8>>,
170    /// Per-column list of chunks that have been flushed to the spill file.
171    /// Each entry is (offset, length) pointing into the spill file.
172    column_chunks: Vec<Vec<(u64, u32)>>,
173    /// Maximum bytes to buffer per column before flushing to the spill file.
174    per_column_limit: usize,
175}
176
177impl PageMetadataSpill {
178    async fn new(object_store: Arc<ObjectStore>, path: Path, num_columns: usize) -> Result<Self> {
179        let writer = object_store.create(&path).await?;
180        let per_column_limit = (DEFAULT_SPILL_BUFFER_LIMIT / num_columns.max(1)).max(64);
181        Ok(Self {
182            writer,
183            object_store,
184            path,
185            position: 0,
186            column_buffers: vec![Vec::new(); num_columns],
187            column_chunks: vec![Vec::new(); num_columns],
188            per_column_limit,
189        })
190    }
191
192    async fn append_page(
193        &mut self,
194        column_idx: usize,
195        page: &pbfile::column_metadata::Page,
196    ) -> Result<()> {
197        page.encode_length_delimited(&mut self.column_buffers[column_idx])
198            .map_err(|e| {
199                Error::io_source(Box::new(std::io::Error::new(
200                    std::io::ErrorKind::InvalidData,
201                    e,
202                )))
203            })?;
204        if self.column_buffers[column_idx].len() >= self.per_column_limit {
205            self.flush_column(column_idx).await?;
206        }
207        Ok(())
208    }
209
210    async fn flush_column(&mut self, column_idx: usize) -> Result<()> {
211        let buf = &self.column_buffers[column_idx];
212        if buf.is_empty() {
213            return Ok(());
214        }
215        let len = buf.len();
216        self.writer.write_all(buf).await?;
217        self.column_chunks[column_idx].push((self.position, len as u32));
218        self.position += len as u64;
219        self.column_buffers[column_idx].clear();
220        Ok(())
221    }
222
223    async fn shutdown_writer(&mut self) -> Result<()> {
224        for col_idx in 0..self.column_buffers.len() {
225            self.flush_column(col_idx).await?;
226        }
227        Writer::shutdown(self.writer.as_mut()).await?;
228        Ok(())
229    }
230}
231
232fn decode_spilled_chunk(data: &Bytes) -> Result<Vec<pbfile::column_metadata::Page>> {
233    let mut pages = Vec::new();
234    let mut cursor = data.clone();
235    while cursor.has_remaining() {
236        let page =
237            pbfile::column_metadata::Page::decode_length_delimited(&mut cursor).map_err(|e| {
238                Error::io_source(Box::new(std::io::Error::new(
239                    std::io::ErrorKind::InvalidData,
240                    e,
241                )))
242            })?;
243        pages.push(page);
244    }
245    Ok(pages)
246}
247
248enum PageSpillState {
249    Pending(Arc<ObjectStore>, Path),
250    Active(PageMetadataSpill),
251}
252
253pub struct FileWriter {
254    writer: Box<dyn Writer>,
255    schema: Option<LanceSchema>,
256    column_writers: Vec<Box<dyn FieldEncoder>>,
257    column_metadata: Vec<pbfile::ColumnMetadata>,
258    field_id_to_column_indices: Vec<(u32, u32)>,
259    num_columns: u32,
260    rows_written: u64,
261    // The number of rows written for each top-level field (i.e. each entry in
262    // `column_writers`). With `write_batch` every field advances together and
263    // these are all equal, but `write_column` advances one field at a time, so
264    // a single file may end up with columns of differing item counts.
265    field_rows_written: Vec<u64>,
266    global_buffers: Vec<(u64, u64)>,
267    schema_metadata: HashMap<String, String>,
268    options: FileWriterOptions,
269    page_spill: Option<PageSpillState>,
270}
271
272fn initial_column_metadata() -> pbfile::ColumnMetadata {
273    pbfile::ColumnMetadata {
274        pages: Vec::new(),
275        buffer_offsets: Vec::new(),
276        buffer_sizes: Vec::new(),
277        encoding: None,
278    }
279}
280
281static WARNED_ON_UNSTABLE_API: AtomicBool = AtomicBool::new(false);
282
283impl FileWriter {
284    /// Create a new FileWriter with a desired output schema
285    pub fn try_new(
286        object_writer: Box<dyn Writer>,
287        schema: LanceSchema,
288        options: FileWriterOptions,
289    ) -> Result<Self> {
290        let mut writer = Self::new_lazy(object_writer, options);
291        writer.initialize(schema)?;
292        Ok(writer)
293    }
294
295    /// Create a new FileWriter without a desired output schema
296    ///
297    /// The output schema will be set based on the first batch of data to arrive.
298    /// If no data arrives and the writer is finished then the write will fail.
299    pub fn new_lazy(object_writer: Box<dyn Writer>, options: FileWriterOptions) -> Self {
300        if let Some(format_version) = options.format_version
301            && format_version.is_unstable()
302            && WARNED_ON_UNSTABLE_API
303                .compare_exchange(
304                    false,
305                    true,
306                    std::sync::atomic::Ordering::Relaxed,
307                    std::sync::atomic::Ordering::Relaxed,
308                )
309                .is_ok()
310        {
311            warn!(
312                "You have requested an unstable format version.  Files written with this format version may not be readable in the future!  This is a development feature and should only be used for experimentation and never for production data."
313            );
314        }
315        Self {
316            writer: object_writer,
317            schema: None,
318            column_writers: Vec::new(),
319            column_metadata: Vec::new(),
320            num_columns: 0,
321            rows_written: 0,
322            field_rows_written: Vec::new(),
323            field_id_to_column_indices: Vec::new(),
324            global_buffers: Vec::new(),
325            schema_metadata: HashMap::new(),
326            page_spill: None,
327            options,
328        }
329    }
330
331    /// Spill page metadata to a sidecar file instead of accumulating in memory.
332    ///
333    /// This can dramatically reduce memory usage when many writers are open
334    /// concurrently (e.g. IVF shuffle with thousands of partition writers).
335    /// The sidecar file is created lazily on the first page write. The caller
336    /// is responsible for cleaning up `path` (e.g. by placing it in a temp
337    /// directory that is removed via RAII).
338    pub fn with_page_metadata_spill(mut self, object_store: Arc<ObjectStore>, path: Path) -> Self {
339        self.page_spill = Some(PageSpillState::Pending(object_store, path));
340        self
341    }
342
343    /// Write a series of record batches to a new file
344    ///
345    /// Returns the number of rows written
346    pub async fn create_file_with_batches(
347        store: &ObjectStore,
348        path: &Path,
349        schema: lance_core::datatypes::Schema,
350        batches: impl Iterator<Item = RecordBatch> + Send,
351        options: FileWriterOptions,
352    ) -> Result<usize> {
353        let writer = store.create(path).await?;
354        let mut writer = Self::try_new(writer, schema, options)?;
355        for batch in batches {
356            writer.write_batch(&batch).await?;
357        }
358        Ok(writer.finish().await?.num_rows as usize)
359    }
360
361    async fn do_write_buffer(writer: &mut (impl AsyncWrite + Unpin), buf: &[u8]) -> Result<()> {
362        writer.write_all(buf).await?;
363        let pad_bytes = pad_bytes::<PAGE_BUFFER_ALIGNMENT>(buf.len());
364        writer.write_all(&PAD_BUFFER[..pad_bytes]).await?;
365        Ok(())
366    }
367
368    /// Returns the format version that will be used when writing the file
369    pub fn version(&self) -> LanceFileVersion {
370        self.options.format_version.unwrap_or_default()
371    }
372
373    async fn write_page(&mut self, encoded_page: EncodedPage) -> Result<()> {
374        let buffers = encoded_page.data;
375        let mut buffer_offsets = Vec::with_capacity(buffers.len());
376        let mut buffer_sizes = Vec::with_capacity(buffers.len());
377        for buffer in buffers {
378            buffer_offsets.push(self.writer.tell().await? as u64);
379            buffer_sizes.push(buffer.len() as u64);
380            Self::do_write_buffer(&mut self.writer, &buffer).await?;
381        }
382        let encoded_encoding = match encoded_page.description {
383            PageEncoding::Legacy(array_encoding) => Any::from_msg(&array_encoding)?.encode_to_vec(),
384            PageEncoding::Structural(page_layout) => Any::from_msg(&page_layout)?.encode_to_vec(),
385        };
386        let page = pbfile::column_metadata::Page {
387            buffer_offsets,
388            buffer_sizes,
389            encoding: Some(pbfile::Encoding {
390                location: Some(pbfile::encoding::Location::Direct(DirectEncoding {
391                    encoding: encoded_encoding,
392                })),
393            }),
394            length: encoded_page.num_rows,
395            priority: encoded_page.row_number,
396        };
397        let col_idx = encoded_page.column_idx as usize;
398        if matches!(&self.page_spill, Some(PageSpillState::Pending(..))) {
399            let Some(PageSpillState::Pending(store, path)) = self.page_spill.take() else {
400                unreachable!()
401            };
402            self.page_spill = Some(PageSpillState::Active(
403                PageMetadataSpill::new(store, path, self.num_columns as usize).await?,
404            ));
405        }
406        match &mut self.page_spill {
407            Some(PageSpillState::Active(spill)) => spill.append_page(col_idx, &page).await?,
408            None => self.column_metadata[col_idx].pages.push(page),
409            Some(PageSpillState::Pending(..)) => unreachable!(),
410        }
411        Ok(())
412    }
413
414    #[instrument(skip_all, level = "debug")]
415    async fn write_pages(&mut self, mut encoding_tasks: FuturesOrdered<EncodeTask>) -> Result<()> {
416        // As soon as an encoding task is done we write it.  There is no parallelism
417        // needed here because "writing" is really just submitting the buffer to the
418        // underlying write scheduler (either the OS or object_store's scheduler for
419        // cloud writes).  The only time we might truly await on write_page is if the
420        // scheduler's write queue is full.
421        //
422        // Also, there is no point in trying to make write_page parallel anyways
423        // because we wouldn't want buffers getting mixed up across pages.
424        while let Some(encoding_task) = encoding_tasks.next().await {
425            let encoded_page = encoding_task?;
426            self.write_page(encoded_page).await?;
427        }
428        // It's important to flush here, we don't know when the next batch will arrive
429        // and the underlying cloud store could have writes in progress that won't advance
430        // until we interact with the writer again.  These in-progress writes will time out
431        // if we don't flush.
432        self.writer.flush().await?;
433        Ok(())
434    }
435
436    /// Schedule batches of data to be written to the file
437    pub async fn write_batches(
438        &mut self,
439        batches: impl Iterator<Item = &RecordBatch>,
440    ) -> Result<()> {
441        for batch in batches {
442            self.write_batch(batch).await?;
443        }
444        Ok(())
445    }
446
447    fn verify_field_nullability(arr: &ArrayData, field: &Field) -> Result<()> {
448        if !field.nullable && arr.null_count() > 0 {
449            return Err(Error::invalid_input(format!(
450                "The field `{}` contained null values even though the field is marked non-null in the schema",
451                field.name
452            )));
453        }
454
455        for (child_field, child_arr) in field.children.iter().zip(arr.child_data()) {
456            Self::verify_field_nullability(child_arr, child_field)?;
457        }
458
459        Ok(())
460    }
461
462    fn verify_nullability_constraints(&self, batch: &RecordBatch) -> Result<()> {
463        for (col, field) in batch
464            .columns()
465            .iter()
466            .zip(self.schema.as_ref().unwrap().fields.iter())
467        {
468            Self::verify_field_nullability(&col.to_data(), field)?;
469        }
470        Ok(())
471    }
472
473    fn initialize(&mut self, mut schema: LanceSchema) -> Result<()> {
474        let cache_bytes_per_column = if let Some(data_cache_bytes) = self.options.data_cache_bytes {
475            data_cache_bytes / schema.fields.len() as u64
476        } else {
477            8 * 1024 * 1024
478        };
479
480        let max_page_bytes = self.options.max_page_bytes.unwrap_or_else(|| {
481            std::env::var(ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES)
482                .map(|s| {
483                    s.parse::<u64>().unwrap_or_else(|e| {
484                        warn!(
485                            "Failed to parse {}: {}, using default",
486                            ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES, e
487                        );
488                        MAX_PAGE_BYTES as u64
489                    })
490                })
491                .unwrap_or(MAX_PAGE_BYTES as u64)
492        });
493
494        schema.validate()?;
495
496        let keep_original_array = self.options.keep_original_array.unwrap_or(false);
497        let encoding_strategy = self
498            .options
499            .encoding_strategy
500            .clone()
501            .unwrap_or_else(|| encoding_strategy(self.version()));
502
503        let encoding_options = EncodingOptions {
504            cache_bytes_per_column,
505            max_page_bytes,
506            keep_original_array,
507            buffer_alignment: PAGE_BUFFER_ALIGNMENT as u64,
508        };
509        let encoder =
510            BatchEncoder::try_new(&schema, encoding_strategy.as_ref(), &encoding_options)?;
511        self.num_columns = encoder.num_columns();
512
513        self.field_rows_written = vec![0; encoder.field_encoders.len()];
514        self.column_writers = encoder.field_encoders;
515        self.column_metadata = vec![initial_column_metadata(); self.num_columns as usize];
516        self.field_id_to_column_indices = encoder.field_id_to_column_index;
517        self.schema_metadata
518            .extend(std::mem::take(&mut schema.metadata));
519        self.schema = Some(schema);
520        Ok(())
521    }
522
523    fn ensure_initialized(&mut self, batch: &RecordBatch) -> Result<&LanceSchema> {
524        if self.schema.is_none() {
525            let schema = LanceSchema::try_from(batch.schema().as_ref())?;
526            self.initialize(schema)?;
527        }
528        Ok(self.schema.as_ref().unwrap())
529    }
530
531    #[instrument(skip_all, level = "debug")]
532    fn encode_batch(
533        &mut self,
534        batch: &RecordBatch,
535        external_buffers: &mut OutOfLineBuffers,
536    ) -> Result<Vec<Vec<EncodeTask>>> {
537        let field_arrays = self
538            .schema
539            .as_ref()
540            .unwrap()
541            .fields
542            .iter()
543            .enumerate()
544            .map(|(field_idx, field)| {
545                let array =
546                    batch
547                        .column_by_name(&field.name)
548                        .ok_or(Error::invalid_input_source(
549                            format!(
550                                "Cannot write batch.  The batch was missing the column `{}`",
551                                field.name
552                            )
553                            .into(),
554                        ))?;
555                Ok((field_idx, array.clone()))
556            })
557            .collect::<Result<Vec<_>>>()?;
558        self.encode_columns(&field_arrays, external_buffers)
559    }
560
561    // Encode a set of `(field index, array)` pairs, each advancing only its own
562    // column. Each task captures its field's current row offset at encode time,
563    // so `advance_columns` must run after this call (never before); the order of
564    // the returned tasks relative to `write_pages` does not matter.
565    fn encode_columns(
566        &mut self,
567        field_arrays: &[(usize, ArrayRef)],
568        external_buffers: &mut OutOfLineBuffers,
569    ) -> Result<Vec<Vec<EncodeTask>>> {
570        // Snapshot the starting row number of each field before borrowing the
571        // column writers mutably below.
572        let row_numbers = field_arrays
573            .iter()
574            .map(|(field_idx, _)| self.field_rows_written[*field_idx])
575            .collect::<Vec<_>>();
576        field_arrays
577            .iter()
578            .zip(row_numbers)
579            .map(|((field_idx, array), row_number)| {
580                let repdef = RepDefBuilder::default();
581                let num_rows = array.len() as u64;
582                self.column_writers[*field_idx].maybe_encode(
583                    array.clone(),
584                    external_buffers,
585                    repdef,
586                    row_number,
587                    num_rows,
588                )
589            })
590            .collect::<Result<Vec<_>>>()
591    }
592
593    // Advance the per-field row counters after a set of columns has been
594    // written, keeping `rows_written` (the file's logical length) in sync as the
595    // longest column. Only the written fields move, so their new totals fold into
596    // `rows_written` directly without rescanning every field. (`write_batch`
597    // advances every field uniformly and tracks this inline instead.)
598    fn advance_columns(&mut self, field_arrays: &[(usize, ArrayRef)]) {
599        for (field_idx, array) in field_arrays {
600            let new_total = self.field_rows_written[*field_idx] + array.len() as u64;
601            self.field_rows_written[*field_idx] = new_total;
602            self.rows_written = self.rows_written.max(new_total);
603        }
604    }
605
606    /// Schedule a batch of data to be written to the file
607    ///
608    /// Note: the future returned by this method may complete before the data has been fully
609    /// flushed to the file (some data may be in the data cache or the I/O cache)
610    pub async fn write_batch(&mut self, batch: &RecordBatch) -> Result<()> {
611        debug!(
612            "write_batch called with {} rows, {} columns, and {} bytes of data",
613            batch.num_rows(),
614            batch.num_columns(),
615            batch.get_array_memory_size()
616        );
617        self.ensure_initialized(batch)?;
618        self.verify_nullability_constraints(batch)?;
619        let num_rows = batch.num_rows() as u64;
620        if num_rows == 0 {
621            return Ok(());
622        }
623        if num_rows > u32::MAX as u64 {
624            return Err(Error::invalid_input_source(
625                "cannot write Lance files with more than 2^32 rows".into(),
626            ));
627        }
628        // First we push each array into its column writer.  This may or may not generate enough
629        // data to trigger an encoding task.  We collect any encoding tasks into a queue.
630        let mut external_buffers =
631            OutOfLineBuffers::new(self.tell().await?, PAGE_BUFFER_ALIGNMENT as u64);
632        let encoding_tasks = self.encode_batch(batch, &mut external_buffers)?;
633        // Next, write external buffers
634        for external_buffer in external_buffers.take_buffers() {
635            Self::do_write_buffer(&mut self.writer, &external_buffer).await?;
636        }
637
638        let encoding_tasks = encoding_tasks
639            .into_iter()
640            .flatten()
641            .collect::<FuturesOrdered<_>>();
642
643        // `write_batch` advances every field by the same amount, so the longest
644        // column simply grows by `num_rows`. Guard against overflowing the row
645        // counter.
646        if self.rows_written.checked_add(num_rows).is_none() {
647            return Err(Error::invalid_input_source(format!("cannot write batch with {} rows because {} rows have already been written and Lance files cannot contain more than 2^64 rows", num_rows, self.rows_written).into()));
648        }
649        for field_rows in self.field_rows_written.iter_mut() {
650            *field_rows += num_rows;
651        }
652        self.rows_written += num_rows;
653
654        self.write_pages(encoding_tasks).await?;
655
656        Ok(())
657    }
658
659    /// Write a single column, advancing only that column's row counter.
660    ///
661    /// Unlike [`write_batch`](Self::write_batch), which advances every column
662    /// from a single shared row counter, this method advances one column
663    /// independently. Used across calls it produces a single file whose columns
664    /// may have different item counts.
665    ///
666    /// `column_index` refers to a top-level field in the writer's schema (the
667    /// same order as the schema's fields); a nested child cannot be targeted on
668    /// its own. Because each call writes the whole field from a single array, the
669    /// children of a struct field always advance together and stay equal-length;
670    /// only different top-level fields can diverge in length. A column may be
671    /// written across multiple calls; its values are appended. A field that is
672    /// never written ends up as a zero-length column. The writer must have been
673    /// created with an explicit schema (via [`try_new`](Self::try_new)); a lazy
674    /// schema cannot be inferred here because individual calls need not cover
675    /// every field.
676    ///
677    /// ```
678    /// # use arrow_array::{ArrayRef, Int32Array};
679    /// # use std::sync::Arc;
680    /// # use lance_file::writer::FileWriter;
681    /// # async fn example(writer: &mut FileWriter) -> lance_core::Result<()> {
682    /// // Field 0 gets three values, field 1 gets one — a non-rectangular file.
683    /// writer.write_column(0, Arc::new(Int32Array::from(vec![1, 2, 3]))).await?;
684    /// writer.write_column(1, Arc::new(Int32Array::from(vec![10]))).await?;
685    /// # Ok(())
686    /// # }
687    /// ```
688    pub async fn write_column(&mut self, column_index: usize, array: ArrayRef) -> Result<()> {
689        let schema = self.schema.as_ref().ok_or_else(|| {
690            Error::invalid_input_source(
691                "write_column requires the writer to be created with an explicit schema".into(),
692            )
693        })?;
694        let field = schema.fields.get(column_index).ok_or_else(|| {
695            Error::invalid_input_source(
696                format!(
697                    "write_column: field index {} is out of bounds (schema has {} fields)",
698                    column_index,
699                    schema.fields.len()
700                )
701                .into(),
702            )
703        })?;
704        if array.len() as u64 > u32::MAX as u64 {
705            return Err(Error::invalid_input_source(
706                "cannot write Lance files with more than 2^32 rows".into(),
707            ));
708        }
709        Self::verify_field_nullability(&array.to_data(), field)?;
710
711        // A never-advanced field simply remains a zero-length column, which the
712        // encoders handle at `finish` time.
713        if array.is_empty() {
714            return Ok(());
715        }
716
717        let columns = [(column_index, array)];
718        let mut external_buffers =
719            OutOfLineBuffers::new(self.tell().await?, PAGE_BUFFER_ALIGNMENT as u64);
720        let encoding_tasks = self.encode_columns(&columns, &mut external_buffers)?;
721        for external_buffer in external_buffers.take_buffers() {
722            Self::do_write_buffer(&mut self.writer, &external_buffer).await?;
723        }
724        let encoding_tasks = encoding_tasks
725            .into_iter()
726            .flatten()
727            .collect::<FuturesOrdered<_>>();
728
729        self.advance_columns(&columns);
730        self.write_pages(encoding_tasks).await?;
731        Ok(())
732    }
733
734    async fn write_column_metadata(
735        &mut self,
736        metadata: pbfile::ColumnMetadata,
737    ) -> Result<(u64, u64)> {
738        let metadata_bytes = metadata.encode_to_vec();
739        let position = self.writer.tell().await? as u64;
740        let len = metadata_bytes.len() as u64;
741        self.writer.write_all(&metadata_bytes).await?;
742        Ok((position, len))
743    }
744
745    async fn write_column_metadatas(&mut self) -> Result<Vec<(u64, u64)>> {
746        let metadatas = std::mem::take(&mut self.column_metadata);
747
748        // If spilling, finalize the spill writer and reopen for reading.
749        // The spill file itself is cleaned up by the caller (it lives in a
750        // temp directory managed by the caller's RAII guard).
751        let spill_state = self.page_spill.take();
752        let (spill_chunks, spill_reader) =
753            if let Some(PageSpillState::Active(mut spill)) = spill_state {
754                spill.shutdown_writer().await?;
755                let reader = spill.object_store.open(&spill.path).await?;
756                let chunks = std::mem::take(&mut spill.column_chunks);
757                (chunks, Some(reader))
758            } else {
759                (Vec::new(), None)
760            };
761
762        let mut metadata_positions = Vec::with_capacity(metadatas.len());
763        for (col_idx, mut metadata) in metadatas.into_iter().enumerate() {
764            if let Some(reader) = &spill_reader {
765                let mut pages = Vec::new();
766                for &(offset, len) in &spill_chunks[col_idx] {
767                    let data = reader
768                        .get_range(offset as usize..(offset as usize + len as usize))
769                        .await
770                        .map_err(|e| Error::io_source(Box::new(e)))?;
771                    pages.extend(decode_spilled_chunk(&data)?);
772                }
773                metadata.pages = pages;
774            }
775            metadata_positions.push(self.write_column_metadata(metadata).await?);
776        }
777
778        Ok(metadata_positions)
779    }
780
781    fn make_file_descriptor(
782        schema: &lance_core::datatypes::Schema,
783        num_rows: u64,
784    ) -> Result<pb::FileDescriptor> {
785        let fields_with_meta = FieldsWithMeta::from(schema);
786        Ok(pb::FileDescriptor {
787            schema: Some(pb::Schema {
788                fields: fields_with_meta.fields.0,
789                metadata: fields_with_meta.metadata,
790            }),
791            length: num_rows,
792        })
793    }
794
795    async fn write_global_buffers(&mut self) -> Result<Vec<(u64, u64)>> {
796        let schema = self.schema.as_mut().ok_or(Error::invalid_input("No schema provided on writer open and no data provided.  Schema is unknown and file cannot be created"))?;
797        schema.metadata = std::mem::take(&mut self.schema_metadata);
798        // Use descriptor layout for blob v2 fields in the footer to avoid exposing logical child fields.
799        schema
800            .fields
801            .iter_mut()
802            .for_each(|f| f.unload_blobs_recursive());
803
804        let file_descriptor = Self::make_file_descriptor(schema, self.rows_written)?;
805        let file_descriptor_bytes = file_descriptor.encode_to_vec();
806        let file_descriptor_len = file_descriptor_bytes.len() as u64;
807        let file_descriptor_position = self.writer.tell().await? as u64;
808        self.writer.write_all(&file_descriptor_bytes).await?;
809        let mut gbo_table = Vec::with_capacity(1 + self.global_buffers.len());
810        gbo_table.push((file_descriptor_position, file_descriptor_len));
811        gbo_table.append(&mut self.global_buffers);
812        Ok(gbo_table)
813    }
814
815    /// Add a metadata entry to the schema
816    ///
817    /// This method is useful because sometimes the metadata is not known until after the
818    /// data has been written.  This method allows you to alter the schema metadata.  It
819    /// must be called before `finish` is called.
820    pub fn add_schema_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
821        self.schema_metadata.insert(key.into(), value.into());
822    }
823
824    /// Prepare the writer when column data and metadata were produced externally.
825    ///
826    /// This is useful for flows that copy already-encoded pages (e.g., binary copy
827    /// during compaction) where the column buffers have been written directly and we
828    /// only need to write the footer and schema metadata. The provided
829    /// `column_metadata` must describe the buffers already persisted by the
830    /// underlying `ObjectWriter`, and `rows_written` should reflect the total number
831    /// of rows in those buffers.
832    pub fn initialize_with_external_metadata(
833        &mut self,
834        schema: lance_core::datatypes::Schema,
835        column_metadata: Vec<pbfile::ColumnMetadata>,
836        rows_written: u64,
837    ) {
838        self.schema = Some(schema);
839        self.num_columns = column_metadata.len() as u32;
840        self.column_metadata = column_metadata;
841        self.rows_written = rows_written;
842    }
843
844    /// Adds a global buffer to the file
845    ///
846    /// The global buffer can contain any arbitrary bytes.  It will be written to the disk
847    /// immediately.  This method returns the index of the global buffer (this will always
848    /// start at 1 and increment by 1 each time this method is called)
849    pub async fn add_global_buffer(&mut self, buffer: Bytes) -> Result<u32> {
850        let position = self.writer.tell().await? as u64;
851        let len = buffer.len() as u64;
852        Self::do_write_buffer(&mut self.writer, &buffer).await?;
853        self.global_buffers.push((position, len));
854        Ok(self.global_buffers.len() as u32)
855    }
856
857    async fn finish_writers(&mut self) -> Result<()> {
858        let mut col_idx = 0;
859        for mut writer in std::mem::take(&mut self.column_writers) {
860            let mut external_buffers =
861                OutOfLineBuffers::new(self.tell().await?, PAGE_BUFFER_ALIGNMENT as u64);
862            let columns = writer.finish(&mut external_buffers).await?;
863            for buffer in external_buffers.take_buffers() {
864                self.writer.write_all(&buffer).await?;
865            }
866            debug_assert_eq!(
867                columns.len(),
868                writer.num_columns() as usize,
869                "Expected {} columns from column at index {} and got {}",
870                writer.num_columns(),
871                col_idx,
872                columns.len()
873            );
874            for column in columns {
875                for page in column.final_pages {
876                    self.write_page(page).await?;
877                }
878                let column_metadata = &mut self.column_metadata[col_idx];
879                let mut buffer_pos = self.writer.tell().await? as u64;
880                for buffer in column.column_buffers {
881                    column_metadata.buffer_offsets.push(buffer_pos);
882                    let mut size = 0;
883                    Self::do_write_buffer(&mut self.writer, &buffer).await?;
884                    size += buffer.len() as u64;
885                    buffer_pos += size;
886                    column_metadata.buffer_sizes.push(size);
887                }
888                let encoded_encoding = Any::from_msg(&column.encoding)?.encode_to_vec();
889                column_metadata.encoding = Some(pbfile::Encoding {
890                    location: Some(pbfile::encoding::Location::Direct(pbfile::DirectEncoding {
891                        encoding: encoded_encoding,
892                    })),
893                });
894                col_idx += 1;
895            }
896        }
897        if col_idx != self.column_metadata.len() {
898            panic!(
899                "Column writers finished with {} columns but we expected {}",
900                col_idx,
901                self.column_metadata.len()
902            );
903        }
904        Ok(())
905    }
906
907    fn standard_footer_numbers(&self) -> (u16, u16) {
908        let version = self.version();
909        let exact_version = ConcreteFileVersion::from(version);
910        if exact_version == crate::version::ConcreteFileVersion::V1 {
911            panic!("Unsupported version: {}", version);
912        }
913        exact_version.to_standard_footer_numbers()
914    }
915
916    /// Finishes writing the file
917    ///
918    /// This method will wait until all data has been flushed to the file.  Then it
919    /// will write the file metadata and the footer.  It will not return until all
920    /// data has been flushed and the file has been closed.
921    ///
922    /// Returns a summary of the completed file write.
923    pub async fn finish(&mut self) -> Result<FileWriteSummary> {
924        // 1. flush any remaining data and write out those pages
925        let mut external_buffers =
926            OutOfLineBuffers::new(self.tell().await?, PAGE_BUFFER_ALIGNMENT as u64);
927        let encoding_tasks = self
928            .column_writers
929            .iter_mut()
930            .map(|writer| writer.flush(&mut external_buffers))
931            .collect::<Result<Vec<_>>>()?;
932        for external_buffer in external_buffers.take_buffers() {
933            Self::do_write_buffer(&mut self.writer, &external_buffer).await?;
934        }
935        let encoding_tasks = encoding_tasks
936            .into_iter()
937            .flatten()
938            .collect::<FuturesOrdered<_>>();
939        self.write_pages(encoding_tasks).await?;
940
941        if !self.column_writers.is_empty() {
942            self.finish_writers().await?;
943        }
944
945        // 3. write global buffers (we write the schema here)
946        let global_buffer_offsets = self.write_global_buffers().await?;
947        let num_global_buffers = global_buffer_offsets.len() as u32;
948
949        // 4. write the column metadatas
950        let column_metadata_start = self.writer.tell().await? as u64;
951        let metadata_positions = self.write_column_metadatas().await?;
952
953        // 5. write the column metadata offset table
954        let cmo_table_start = self.writer.tell().await? as u64;
955        for (meta_pos, meta_len) in metadata_positions {
956            self.writer.write_u64_le(meta_pos).await?;
957            self.writer.write_u64_le(meta_len).await?;
958        }
959
960        // 6. write global buffers offset table
961        let gbo_table_start = self.writer.tell().await? as u64;
962        for (gbo_pos, gbo_len) in global_buffer_offsets {
963            self.writer.write_u64_le(gbo_pos).await?;
964            self.writer.write_u64_le(gbo_len).await?;
965        }
966
967        let (major, minor) = self.standard_footer_numbers();
968        // 7. write the footer
969        self.writer.write_u64_le(column_metadata_start).await?;
970        self.writer.write_u64_le(cmo_table_start).await?;
971        self.writer.write_u64_le(gbo_table_start).await?;
972        self.writer.write_u32_le(num_global_buffers).await?;
973        self.writer.write_u32_le(self.num_columns).await?;
974        self.writer.write_u16_le(major).await?;
975        self.writer.write_u16_le(minor).await?;
976        self.writer.write_all(MAGIC).await?;
977
978        // 7. close the writer
979        let write_result = Writer::shutdown(self.writer.as_mut()).await?;
980
981        Ok(FileWriteSummary {
982            num_rows: self.rows_written,
983            size_bytes: write_result.size as u64,
984        })
985    }
986
987    pub async fn abort(&mut self) {
988        // For multipart uploads, ObjectWriter's Drop impl will abort
989        // the upload when the writer is dropped.
990    }
991
992    pub async fn tell(&mut self) -> Result<u64> {
993        Ok(self.writer.tell().await? as u64)
994    }
995
996    pub fn field_id_to_column_indices(&self) -> &[(u32, u32)] {
997        &self.field_id_to_column_indices
998    }
999}
1000
1001/// Utility trait for converting EncodedBatch to Bytes using the
1002/// lance file format
1003pub trait EncodedBatchWriteExt {
1004    /// Serializes into a lance file, including the schema
1005    fn try_to_self_described_lance(&self, version: LanceFileVersion) -> Result<Bytes>;
1006    /// Serializes into a lance file, without the schema.
1007    ///
1008    /// The schema must be provided to deserialize the buffer
1009    fn try_to_mini_lance(&self, version: LanceFileVersion) -> Result<Bytes>;
1010}
1011
1012// Creates a lance footer and appends it to the encoded data
1013//
1014// The logic here is very similar to logic in the FileWriter except we
1015// are using BufMut (put_xyz) instead of AsyncWrite (write_xyz).
1016fn concat_lance_footer(
1017    batch: &EncodedBatch,
1018    write_schema: bool,
1019    version: LanceFileVersion,
1020) -> Result<Bytes> {
1021    // Estimating 1MiB for file footer
1022    let mut data = BytesMut::with_capacity(batch.data.len() + 1024 * 1024);
1023    data.put(batch.data.clone());
1024    // write global buffers (we write the schema here)
1025    let global_buffers = if write_schema {
1026        let schema_start = data.len() as u64;
1027        let lance_schema = lance_core::datatypes::Schema::try_from(batch.schema.as_ref())?;
1028        let descriptor = FileWriter::make_file_descriptor(&lance_schema, batch.num_rows)?;
1029        let descriptor_bytes = descriptor.encode_to_vec();
1030        let descriptor_len = descriptor_bytes.len() as u64;
1031        data.put(descriptor_bytes.as_slice());
1032
1033        vec![(schema_start, descriptor_len)]
1034    } else {
1035        vec![]
1036    };
1037    let col_metadata_start = data.len() as u64;
1038
1039    let mut col_metadata_positions = Vec::new();
1040    // Write column metadata
1041    for col in &batch.page_table {
1042        let position = data.len() as u64;
1043        let pages = col
1044            .page_infos
1045            .iter()
1046            .map(|page_info| {
1047                let encoded_encoding = match &page_info.encoding {
1048                    PageEncoding::Legacy(array_encoding) => {
1049                        Any::from_msg(array_encoding)?.encode_to_vec()
1050                    }
1051                    PageEncoding::Structural(page_layout) => {
1052                        Any::from_msg(page_layout)?.encode_to_vec()
1053                    }
1054                };
1055                let (buffer_offsets, buffer_sizes): (Vec<_>, Vec<_>) = page_info
1056                    .buffer_offsets_and_sizes
1057                    .as_ref()
1058                    .iter()
1059                    .cloned()
1060                    .unzip();
1061                Ok(pbfile::column_metadata::Page {
1062                    buffer_offsets,
1063                    buffer_sizes,
1064                    encoding: Some(pbfile::Encoding {
1065                        location: Some(pbfile::encoding::Location::Direct(DirectEncoding {
1066                            encoding: encoded_encoding,
1067                        })),
1068                    }),
1069                    length: page_info.num_rows,
1070                    priority: page_info.priority,
1071                })
1072            })
1073            .collect::<Result<Vec<_>>>()?;
1074        let (buffer_offsets, buffer_sizes): (Vec<_>, Vec<_>) =
1075            col.buffer_offsets_and_sizes.iter().cloned().unzip();
1076        let encoded_col_encoding = Any::from_msg(&col.encoding)?.encode_to_vec();
1077        let column = pbfile::ColumnMetadata {
1078            pages,
1079            buffer_offsets,
1080            buffer_sizes,
1081            encoding: Some(pbfile::Encoding {
1082                location: Some(pbfile::encoding::Location::Direct(pbfile::DirectEncoding {
1083                    encoding: encoded_col_encoding,
1084                })),
1085            }),
1086        };
1087        let column_bytes = column.encode_to_vec();
1088        col_metadata_positions.push((position, column_bytes.len() as u64));
1089        data.put(column_bytes.as_slice());
1090    }
1091    // Write column metadata offsets table
1092    let cmo_table_start = data.len() as u64;
1093    for (meta_pos, meta_len) in col_metadata_positions {
1094        data.put_u64_le(meta_pos);
1095        data.put_u64_le(meta_len);
1096    }
1097    // Write global buffers offsets table
1098    let gbo_table_start = data.len() as u64;
1099    let num_global_buffers = global_buffers.len() as u32;
1100    for (gbo_pos, gbo_len) in global_buffers {
1101        data.put_u64_le(gbo_pos);
1102        data.put_u64_le(gbo_len);
1103    }
1104
1105    let (major, minor) = ConcreteFileVersion::from(version).to_embedded_footer_numbers();
1106
1107    // write the footer
1108    data.put_u64_le(col_metadata_start);
1109    data.put_u64_le(cmo_table_start);
1110    data.put_u64_le(gbo_table_start);
1111    data.put_u32_le(num_global_buffers);
1112    data.put_u32_le(batch.page_table.len() as u32);
1113    data.put_u16_le(major);
1114    data.put_u16_le(minor);
1115    data.put(MAGIC.as_slice());
1116
1117    Ok(data.freeze())
1118}
1119
1120impl EncodedBatchWriteExt for EncodedBatch {
1121    fn try_to_self_described_lance(&self, version: LanceFileVersion) -> Result<Bytes> {
1122        concat_lance_footer(self, true, version)
1123    }
1124
1125    fn try_to_mini_lance(&self, version: LanceFileVersion) -> Result<Bytes> {
1126        concat_lance_footer(self, false, version)
1127    }
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use std::collections::HashMap;
1133    use std::sync::Arc;
1134
1135    use crate::reader::{FileReader, FileReaderOptions, ReaderProjection, describe_encoding};
1136    use crate::testing::FsFixture;
1137    use crate::writer::{ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES, FileWriter, FileWriterOptions};
1138    use arrow_array::builder::{Float32Builder, Int32Builder};
1139    use arrow_array::{ArrayRef, Int32Array, RecordBatch, UInt64Array};
1140    use arrow_array::{RecordBatchReader, StringArray, types::Float64Type};
1141    use arrow_schema::{DataType, Field, Field as ArrowField, Schema, Schema as ArrowSchema};
1142    use lance_core::cache::LanceCache;
1143    use lance_core::datatypes::Schema as LanceSchema;
1144    use lance_core::utils::tempfile::TempObjFile;
1145    use lance_datagen::{BatchCount, RowCount, array, gen_batch};
1146    use lance_encoding::compression_config::{CompressionFieldParams, CompressionParams};
1147    use lance_encoding::decoder::DecoderPlugins;
1148    use lance_encoding::version::LanceFileVersion;
1149    use lance_io::object_store::ObjectStore;
1150    use lance_io::utils::CachedFileSize;
1151    use rstest::rstest;
1152
1153    #[tokio::test]
1154    async fn test_basic_write() {
1155        let tmp_path = TempObjFile::default();
1156        let obj_store = Arc::new(ObjectStore::local());
1157
1158        let reader = gen_batch()
1159            .col("score", array::rand::<Float64Type>())
1160            .into_reader_rows(RowCount::from(1000), BatchCount::from(10));
1161
1162        let writer = obj_store.create(&tmp_path).await.unwrap();
1163
1164        let lance_schema =
1165            lance_core::datatypes::Schema::try_from(reader.schema().as_ref()).unwrap();
1166
1167        let mut file_writer =
1168            FileWriter::try_new(writer, lance_schema, FileWriterOptions::default()).unwrap();
1169
1170        for batch in reader {
1171            file_writer.write_batch(&batch.unwrap()).await.unwrap();
1172        }
1173        file_writer.add_schema_metadata("foo", "bar");
1174        file_writer.finish().await.unwrap();
1175        // Tests asserting the contents of the written file are in reader.rs
1176    }
1177
1178    #[tokio::test]
1179    async fn test_write_empty() {
1180        let tmp_path = TempObjFile::default();
1181        let obj_store = Arc::new(ObjectStore::local());
1182
1183        let reader = gen_batch()
1184            .col("score", array::rand::<Float64Type>())
1185            .into_reader_rows(RowCount::from(0), BatchCount::from(0));
1186
1187        let writer = obj_store.create(&tmp_path).await.unwrap();
1188
1189        let lance_schema =
1190            lance_core::datatypes::Schema::try_from(reader.schema().as_ref()).unwrap();
1191
1192        let mut file_writer =
1193            FileWriter::try_new(writer, lance_schema, FileWriterOptions::default()).unwrap();
1194
1195        for batch in reader {
1196            file_writer.write_batch(&batch.unwrap()).await.unwrap();
1197        }
1198        file_writer.add_schema_metadata("foo", "bar");
1199        file_writer.finish().await.unwrap();
1200    }
1201
1202    // Read a single column back at an explicit range/index set, returning its
1203    // `Int32` values. Reading one column (or an equal-length group) at a time is
1204    // how unequal-length files are consumed: a full scan across columns of
1205    // differing lengths cannot form a single rectangular batch.
1206    async fn read_int32_column(
1207        reader: &FileReader,
1208        schema: &LanceSchema,
1209        version: LanceFileVersion,
1210        name: &str,
1211        params: lance_io::ReadBatchParams,
1212    ) -> Vec<Option<i32>> {
1213        use futures::TryStreamExt;
1214        use lance_encoding::decoder::FilterExpression;
1215
1216        let projection = ReaderProjection::from_column_names(version, schema, &[name]).unwrap();
1217        let batches: Vec<RecordBatch> = reader
1218            .read_stream_projected(params, 1024, 16, projection, FilterExpression::no_filter())
1219            .await
1220            .unwrap()
1221            .try_collect()
1222            .await
1223            .unwrap();
1224        batches
1225            .iter()
1226            .flat_map(|b| {
1227                b.column(0)
1228                    .as_any()
1229                    .downcast_ref::<Int32Array>()
1230                    .unwrap()
1231                    .iter()
1232                    .collect::<Vec<_>>()
1233            })
1234            .collect()
1235    }
1236
1237    /// A single file may hold columns of differing item counts, written by
1238    /// advancing each column's row counter independently (no shared global
1239    /// counter).
1240    #[rstest]
1241    #[tokio::test]
1242    async fn test_write_columns_unequal_lengths(
1243        #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion,
1244    ) {
1245        use lance_io::ReadBatchParams;
1246
1247        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1248            ArrowField::new("a", DataType::Int32, true),
1249            ArrowField::new("b", DataType::Int32, true),
1250            ArrowField::new("c", DataType::Int32, true),
1251        ]));
1252        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1253
1254        let fs = FsFixture::default();
1255        let options = FileWriterOptions {
1256            format_version: Some(version),
1257            ..Default::default()
1258        };
1259        let mut writer = FileWriter::try_new(
1260            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1261            lance_schema.clone(),
1262            options,
1263        )
1264        .unwrap();
1265
1266        // Field "a" gets 5 values across two calls (appending), field "b" gets a
1267        // single value, and field "c" is never written (a zero-length column).
1268        let a1: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3]));
1269        let b: ArrayRef = Arc::new(Int32Array::from(vec![10]));
1270        writer.write_column(0, a1).await.unwrap();
1271        writer.write_column(1, b).await.unwrap();
1272        let a2: ArrayRef = Arc::new(Int32Array::from(vec![4, 5]));
1273        writer.write_column(0, a2).await.unwrap();
1274        // An empty array is a no-op whether or not the field already has rows:
1275        // field "a" keeps its 5 rows, field "c" stays a zero-length column.
1276        let empty: ArrayRef = Arc::new(Int32Array::from(Vec::<i32>::new()));
1277        writer.write_column(0, empty.clone()).await.unwrap();
1278        writer.write_column(2, empty).await.unwrap();
1279
1280        let summary = writer.finish().await.unwrap();
1281        // The file's logical length is the longest column.
1282        assert_eq!(summary.num_rows, 5);
1283
1284        let file_scheduler = fs
1285            .scheduler
1286            .open_file(&fs.tmp_path, &CachedFileSize::unknown())
1287            .await
1288            .unwrap();
1289        let reader = FileReader::try_open(
1290            file_scheduler,
1291            None,
1292            Arc::<DecoderPlugins>::default(),
1293            &LanceCache::no_cache(),
1294            FileReaderOptions::default(),
1295        )
1296        .await
1297        .unwrap();
1298
1299        // Per-column row counts are recorded in / derivable from file metadata.
1300        assert_eq!(reader.num_rows(), 5);
1301        assert_eq!(reader.column_num_rows(0).unwrap(), 5);
1302        assert_eq!(reader.column_num_rows(1).unwrap(), 1);
1303        assert_eq!(reader.column_num_rows(2).unwrap(), 0);
1304        assert!(reader.column_num_rows(3).is_err());
1305
1306        // Each column reads back independently at its own length.
1307        assert_eq!(
1308            read_int32_column(
1309                &reader,
1310                &lance_schema,
1311                version,
1312                "a",
1313                ReadBatchParams::Range(0..5)
1314            )
1315            .await,
1316            vec![Some(1), Some(2), Some(3), Some(4), Some(5)],
1317        );
1318        assert_eq!(
1319            read_int32_column(
1320                &reader,
1321                &lance_schema,
1322                version,
1323                "b",
1324                ReadBatchParams::Range(0..1)
1325            )
1326            .await,
1327            vec![Some(10)],
1328        );
1329
1330        // Random access by position within the longer column returns the right
1331        // value even though other columns are shorter. (The take path requires
1332        // strictly increasing indices.)
1333        assert_eq!(
1334            read_int32_column(
1335                &reader,
1336                &lance_schema,
1337                version,
1338                "a",
1339                ReadBatchParams::Indices(arrow_array::UInt32Array::from(vec![0, 2, 4])),
1340            )
1341            .await,
1342            vec![Some(1), Some(3), Some(5)],
1343        );
1344    }
1345
1346    /// Reading an unequal-length file:
1347    /// - a projection whose columns are equal length full-scans normally;
1348    /// - a full scan across columns of differing length is rejected up front,
1349    ///   before any batch is produced (even though a prefix would be rectangular);
1350    /// - a bounded read is valid as long as every projected column covers it;
1351    /// - a single-column `RangeFull` resolves to that column's own length, not
1352    ///   the file's (maximum) length.
1353    #[rstest]
1354    #[tokio::test]
1355    async fn test_read_unequal_length_projection(
1356        #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion,
1357    ) {
1358        use futures::TryStreamExt;
1359        use lance_encoding::decoder::FilterExpression;
1360        use lance_io::ReadBatchParams;
1361
1362        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1363            ArrowField::new("a", DataType::Int32, true),
1364            ArrowField::new("b", DataType::Int32, true),
1365            ArrowField::new("c", DataType::Int32, true),
1366        ]));
1367        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1368        let fs = FsFixture::default();
1369        let options = FileWriterOptions {
1370            format_version: Some(version),
1371            ..Default::default()
1372        };
1373        let mut writer = FileWriter::try_new(
1374            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1375            lance_schema.clone(),
1376            options,
1377        )
1378        .unwrap();
1379        // "a" and "b" are equal length (5); "c" is shorter (1).
1380        writer
1381            .write_column(0, Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])))
1382            .await
1383            .unwrap();
1384        writer
1385            .write_column(1, Arc::new(Int32Array::from(vec![6, 7, 8, 9, 10])))
1386            .await
1387            .unwrap();
1388        writer
1389            .write_column(2, Arc::new(Int32Array::from(vec![100])))
1390            .await
1391            .unwrap();
1392        writer.finish().await.unwrap();
1393
1394        let file_scheduler = fs
1395            .scheduler
1396            .open_file(&fs.tmp_path, &CachedFileSize::unknown())
1397            .await
1398            .unwrap();
1399        let reader = FileReader::try_open(
1400            file_scheduler,
1401            None,
1402            Arc::<DecoderPlugins>::default(),
1403            &LanceCache::no_cache(),
1404            FileReaderOptions::default(),
1405        )
1406        .await
1407        .unwrap();
1408
1409        let read = |names: &'static [&'static str], params: ReadBatchParams| {
1410            let projection =
1411                ReaderProjection::from_column_names(version, &lance_schema, names).unwrap();
1412            async {
1413                match reader
1414                    .read_stream_projected(
1415                        params,
1416                        1024,
1417                        16,
1418                        projection,
1419                        FilterExpression::no_filter(),
1420                    )
1421                    .await
1422                {
1423                    Ok(stream) => stream.try_collect::<Vec<RecordBatch>>().await,
1424                    Err(e) => Err(e),
1425                }
1426            }
1427        };
1428        let col_values = |batches: &[RecordBatch], idx: usize| -> Vec<Option<i32>> {
1429            batches
1430                .iter()
1431                .flat_map(|b| {
1432                    b.column(idx)
1433                        .as_any()
1434                        .downcast_ref::<Int32Array>()
1435                        .unwrap()
1436                        .iter()
1437                        .collect::<Vec<_>>()
1438                })
1439                .collect()
1440        };
1441
1442        // Equal-length projection [a, b] full-scans into rectangular batches.
1443        let batches = read(&["a", "b"], ReadBatchParams::RangeFull).await.unwrap();
1444        assert_eq!(
1445            col_values(&batches, 0),
1446            vec![Some(1), Some(2), Some(3), Some(4), Some(5)]
1447        );
1448        assert_eq!(
1449            col_values(&batches, 1),
1450            vec![Some(6), Some(7), Some(8), Some(9), Some(10)]
1451        );
1452
1453        // A mismatched-length projection [a, c] (5 vs 1) is rejected before any
1454        // batch is yielded, regardless of the read params — its columns cannot
1455        // be combined into rectangular batches. The error names each column's
1456        // length so the caller can see which column is the odd one out.
1457        let err = read(&["a", "c"], ReadBatchParams::RangeFull)
1458            .await
1459            .unwrap_err()
1460            .to_string();
1461        assert!(
1462            err.contains("a=5") && err.contains("c=1"),
1463            "error should name each column's length, got: {err}"
1464        );
1465        assert!(
1466            read(&["a", "c"], ReadBatchParams::Range(0..1))
1467                .await
1468                .is_err(),
1469            "even a common-prefix read of unequal-length columns must error"
1470        );
1471
1472        // A single-column RangeFull resolves to that column's own length.
1473        let batches = read(&["c"], ReadBatchParams::RangeFull).await.unwrap();
1474        assert_eq!(col_values(&batches, 0), vec![Some(100)]);
1475        let batches = read(&["a"], ReadBatchParams::RangeFull).await.unwrap();
1476        assert_eq!(
1477            col_values(&batches, 0),
1478            vec![Some(1), Some(2), Some(3), Some(4), Some(5)]
1479        );
1480
1481        // RangeFrom/RangeTo likewise resolve against the projected column's own
1482        // length rather than the file's longest column.
1483        let batches = read(&["a"], ReadBatchParams::RangeFrom(2..)).await.unwrap();
1484        assert_eq!(col_values(&batches, 0), vec![Some(3), Some(4), Some(5)]);
1485        // RangeFrom on the short column "c" resolves to length 1, not 5.
1486        let batches = read(&["c"], ReadBatchParams::RangeFrom(0..)).await.unwrap();
1487        assert_eq!(col_values(&batches, 0), vec![Some(100)]);
1488        let batches = read(&["a"], ReadBatchParams::RangeTo(..3)).await.unwrap();
1489        assert_eq!(col_values(&batches, 0), vec![Some(1), Some(2), Some(3)]);
1490        // A bound past the projected column's length errors.
1491        assert!(
1492            read(&["a"], ReadBatchParams::RangeTo(..6)).await.is_err(),
1493            "RangeTo past the column length must error"
1494        );
1495        assert!(
1496            read(&["c"], ReadBatchParams::RangeFrom(2..)).await.is_err(),
1497            "RangeFrom past the column length must error"
1498        );
1499    }
1500
1501    /// A struct and a list column each map to multiple physical columns, and a
1502    /// list's item column is longer than its top-level row count. The
1503    /// projection-length check must partition `column_indices` by top-level
1504    /// field and use each field's root column, so an ordinary (rectangular) file
1505    /// with nested columns still reads under the new validation path.
1506    #[rstest]
1507    #[tokio::test]
1508    async fn test_read_nested_columns_under_validation(
1509        #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion,
1510    ) {
1511        use arrow_array::types::Int32Type;
1512        use arrow_array::{ListArray, StructArray};
1513        use futures::TryStreamExt;
1514        use lance_encoding::decoder::FilterExpression;
1515        use lance_io::ReadBatchParams;
1516
1517        let struct_type = DataType::Struct(
1518            vec![
1519                ArrowField::new("x", DataType::Int32, true),
1520                ArrowField::new("y", DataType::Int32, true),
1521            ]
1522            .into(),
1523        );
1524        let list_type = DataType::List(Arc::new(ArrowField::new("item", DataType::Int32, true)));
1525        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1526            ArrowField::new("a", DataType::Int32, true),
1527            ArrowField::new("s", struct_type, true),
1528            ArrowField::new("lst", list_type, true),
1529        ]));
1530        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1531
1532        let a: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3]));
1533        let s: ArrayRef = Arc::new(StructArray::from(vec![
1534            (
1535                Arc::new(ArrowField::new("x", DataType::Int32, true)),
1536                Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef,
1537            ),
1538            (
1539                Arc::new(ArrowField::new("y", DataType::Int32, true)),
1540                Arc::new(Int32Array::from(vec![11, 21, 31])) as ArrayRef,
1541            ),
1542        ]));
1543        // 3 lists, 6 items: the item column is longer than the top-level rows.
1544        let lst: ArrayRef = Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(vec![
1545            Some(vec![Some(1), Some(2)]),
1546            Some(vec![Some(3)]),
1547            Some(vec![Some(4), Some(5), Some(6)]),
1548        ]));
1549        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![a, s, lst]).unwrap();
1550
1551        let fs = FsFixture::default();
1552        let options = FileWriterOptions {
1553            format_version: Some(version),
1554            ..Default::default()
1555        };
1556        let mut writer = FileWriter::try_new(
1557            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1558            lance_schema.clone(),
1559            options,
1560        )
1561        .unwrap();
1562        writer.write_batch(&batch).await.unwrap();
1563        writer.finish().await.unwrap();
1564
1565        let file_scheduler = fs
1566            .scheduler
1567            .open_file(&fs.tmp_path, &CachedFileSize::unknown())
1568            .await
1569            .unwrap();
1570        let reader = FileReader::try_open(
1571            file_scheduler,
1572            None,
1573            Arc::<DecoderPlugins>::default(),
1574            &LanceCache::no_cache(),
1575            FileReaderOptions::default(),
1576        )
1577        .await
1578        .unwrap();
1579
1580        // If `validate_field_length` mispartitioned the physical columns, the
1581        // length check would read the wrong root column (e.g. the list's item
1582        // column, length 6) and spuriously reject this rectangular file.
1583        for names in [&["a", "s", "lst"][..], &["a", "lst"][..], &["a", "s"][..]] {
1584            let projection =
1585                ReaderProjection::from_column_names(version, &lance_schema, names).unwrap();
1586            let batches: Vec<RecordBatch> = reader
1587                .read_stream_projected(
1588                    ReadBatchParams::RangeFull,
1589                    1024,
1590                    16,
1591                    projection,
1592                    FilterExpression::no_filter(),
1593                )
1594                .await
1595                .unwrap()
1596                .try_collect()
1597                .await
1598                .unwrap();
1599            let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1600            assert_eq!(
1601                total_rows, 3,
1602                "projection {names:?} should read 3 top-level rows"
1603            );
1604        }
1605    }
1606
1607    /// `write_column` rejects invalid inputs at the API boundary with
1608    /// descriptive errors: a writer without an explicit schema, an
1609    /// out-of-bounds field index, and a null written into a non-nullable field.
1610    #[tokio::test]
1611    async fn test_write_column_validation_errors() {
1612        // A lazy-schema writer cannot infer the schema from a single column.
1613        let fs = FsFixture::default();
1614        let mut lazy_writer = FileWriter::new_lazy(
1615            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1616            FileWriterOptions::default(),
1617        );
1618        let err = lazy_writer
1619            .write_column(0, Arc::new(Int32Array::from(vec![1, 2, 3])))
1620            .await
1621            .unwrap_err()
1622            .to_string();
1623        assert!(
1624            err.contains("explicit schema"),
1625            "expected explicit-schema error, got: {err}"
1626        );
1627
1628        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1629            ArrowField::new("a", DataType::Int32, false),
1630            ArrowField::new("b", DataType::Int32, true),
1631        ]));
1632        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1633
1634        // An out-of-bounds field index is rejected, naming the index and count.
1635        let fs = FsFixture::default();
1636        let mut writer = FileWriter::try_new(
1637            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1638            lance_schema.clone(),
1639            FileWriterOptions::default(),
1640        )
1641        .unwrap();
1642        let err = writer
1643            .write_column(5, Arc::new(Int32Array::from(vec![1])))
1644            .await
1645            .unwrap_err()
1646            .to_string();
1647        assert!(
1648            err.contains('5') && err.contains('2'),
1649            "expected out-of-bounds error naming index 5 and 2 fields, got: {err}"
1650        );
1651
1652        // A null in a non-nullable field ("a") is rejected.
1653        let err = writer
1654            .write_column(0, Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])))
1655            .await
1656            .unwrap_err()
1657            .to_string();
1658        assert!(
1659            err.contains("non-null"),
1660            "expected nullability error, got: {err}"
1661        );
1662    }
1663
1664    /// The blocking read path applies the same projection-length validation as
1665    /// the async path: a short single column resolves to its own length, and a
1666    /// mismatched-length projection errors up front.
1667    #[rstest]
1668    #[tokio::test]
1669    async fn test_blocking_read_unequal_length(
1670        #[values(LanceFileVersion::V2_0, LanceFileVersion::V2_1)] version: LanceFileVersion,
1671    ) {
1672        use lance_encoding::decoder::FilterExpression;
1673        use lance_io::ReadBatchParams;
1674
1675        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1676            ArrowField::new("a", DataType::Int32, true),
1677            ArrowField::new("c", DataType::Int32, true),
1678        ]));
1679        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1680        let fs = FsFixture::default();
1681        let options = FileWriterOptions {
1682            format_version: Some(version),
1683            ..Default::default()
1684        };
1685        let mut writer = FileWriter::try_new(
1686            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1687            lance_schema.clone(),
1688            options,
1689        )
1690        .unwrap();
1691        writer
1692            .write_column(0, Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])))
1693            .await
1694            .unwrap();
1695        writer
1696            .write_column(1, Arc::new(Int32Array::from(vec![100])))
1697            .await
1698            .unwrap();
1699        writer.finish().await.unwrap();
1700
1701        let file_scheduler = fs
1702            .scheduler
1703            .open_file(&fs.tmp_path, &CachedFileSize::unknown())
1704            .await
1705            .unwrap();
1706        let reader = Arc::new(
1707            FileReader::try_open(
1708                file_scheduler,
1709                None,
1710                Arc::<DecoderPlugins>::default(),
1711                &LanceCache::no_cache(),
1712                FileReaderOptions::default(),
1713            )
1714            .await
1715            .unwrap(),
1716        );
1717
1718        // Single short column: RangeFull resolves to its own length (1).
1719        let proj_c = ReaderProjection::from_column_names(version, &lance_schema, &["c"]).unwrap();
1720        let reader_c = reader.clone();
1721        let batches = tokio::task::spawn_blocking(move || {
1722            reader_c
1723                .read_stream_projected_blocking(
1724                    ReadBatchParams::RangeFull,
1725                    1024,
1726                    Some(proj_c),
1727                    FilterExpression::no_filter(),
1728                )
1729                .unwrap()
1730                .collect::<std::result::Result<Vec<RecordBatch>, _>>()
1731                .unwrap()
1732        })
1733        .await
1734        .unwrap();
1735        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1736        assert_eq!(total_rows, 1);
1737
1738        // A mismatched projection [a, c] errors on the blocking path too.
1739        let proj_ac =
1740            ReaderProjection::from_column_names(version, &lance_schema, &["a", "c"]).unwrap();
1741        let reader_ac = reader.clone();
1742        let is_err = tokio::task::spawn_blocking(move || {
1743            reader_ac
1744                .read_stream_projected_blocking(
1745                    ReadBatchParams::RangeFull,
1746                    1024,
1747                    Some(proj_ac),
1748                    FilterExpression::no_filter(),
1749                )
1750                .is_err()
1751        })
1752        .await
1753        .unwrap();
1754        assert!(
1755            is_err,
1756            "blocking full scan across unequal-length columns must error"
1757        );
1758    }
1759
1760    /// Files written the ordinary (rectangular) way keep equal column lengths,
1761    /// so the unequal-length support is backwards compatible.
1762    #[tokio::test]
1763    async fn test_write_batch_keeps_equal_lengths() {
1764        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1765            ArrowField::new("a", DataType::Int32, true),
1766            ArrowField::new("b", DataType::Int32, true),
1767        ]));
1768        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1769
1770        let fs = FsFixture::default();
1771        let mut writer = FileWriter::try_new(
1772            fs.object_store.create(&fs.tmp_path).await.unwrap(),
1773            lance_schema,
1774            FileWriterOptions::default(),
1775        )
1776        .unwrap();
1777        let batch = RecordBatch::try_new(
1778            arrow_schema.clone(),
1779            vec![
1780                Arc::new(Int32Array::from(vec![1, 2, 3])),
1781                Arc::new(Int32Array::from(vec![4, 5, 6])),
1782            ],
1783        )
1784        .unwrap();
1785        writer.write_batch(&batch).await.unwrap();
1786        let summary = writer.finish().await.unwrap();
1787        assert_eq!(summary.num_rows, 3);
1788
1789        let file_scheduler = fs
1790            .scheduler
1791            .open_file(&fs.tmp_path, &CachedFileSize::unknown())
1792            .await
1793            .unwrap();
1794        let reader = FileReader::try_open(
1795            file_scheduler,
1796            None,
1797            Arc::<DecoderPlugins>::default(),
1798            &LanceCache::no_cache(),
1799            FileReaderOptions::default(),
1800        )
1801        .await
1802        .unwrap();
1803        assert_eq!(reader.column_num_rows(0).unwrap(), 3);
1804        assert_eq!(reader.column_num_rows(1).unwrap(), 3);
1805    }
1806
1807    #[tokio::test]
1808    async fn test_max_page_bytes_enforced() {
1809        let arrow_field = Field::new("data", DataType::UInt64, false);
1810        let arrow_schema = Schema::new(vec![arrow_field]);
1811        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
1812
1813        // 8MiB
1814        let data: Vec<u64> = (0..1_000_000).collect();
1815        let array = UInt64Array::from(data);
1816        let batch =
1817            RecordBatch::try_new(arrow_schema.clone().into(), vec![Arc::new(array)]).unwrap();
1818
1819        let options = FileWriterOptions {
1820            max_page_bytes: Some(1024 * 1024), // 1MB
1821            // This is a 2.0 only test because 2.1+ splits large pages on read instead of write
1822            format_version: Some(LanceFileVersion::V2_0),
1823            ..Default::default()
1824        };
1825
1826        let path = TempObjFile::default();
1827        let object_store = ObjectStore::local();
1828        let mut writer = FileWriter::try_new(
1829            object_store.create(&path).await.unwrap(),
1830            lance_schema,
1831            options,
1832        )
1833        .unwrap();
1834
1835        writer.write_batch(&batch).await.unwrap();
1836        writer.finish().await.unwrap();
1837
1838        let fs = FsFixture::default();
1839        let file_scheduler = fs
1840            .scheduler
1841            .open_file(&path, &CachedFileSize::unknown())
1842            .await
1843            .unwrap();
1844        let file_reader = FileReader::try_open(
1845            file_scheduler,
1846            None,
1847            Arc::<DecoderPlugins>::default(),
1848            &LanceCache::no_cache(),
1849            FileReaderOptions::default(),
1850        )
1851        .await
1852        .unwrap();
1853
1854        let column_meta = file_reader.metadata();
1855
1856        let mut total_page_num: u32 = 0;
1857        for (col_idx, col_metadata) in column_meta.column_metadatas.iter().enumerate() {
1858            assert!(
1859                !col_metadata.pages.is_empty(),
1860                "Column {} has no pages",
1861                col_idx
1862            );
1863
1864            for (page_idx, page) in col_metadata.pages.iter().enumerate() {
1865                total_page_num += 1;
1866                let total_size: u64 = page.buffer_sizes.iter().sum();
1867                assert!(
1868                    total_size <= 1024 * 1024,
1869                    "Column {} Page {} size {} exceeds 1MB limit",
1870                    col_idx,
1871                    page_idx,
1872                    total_size
1873                );
1874            }
1875        }
1876
1877        assert_eq!(total_page_num, 8)
1878    }
1879
1880    #[tokio::test(flavor = "current_thread")]
1881    async fn test_max_page_bytes_env_var() {
1882        let arrow_field = Field::new("data", DataType::UInt64, false);
1883        let arrow_schema = Schema::new(vec![arrow_field]);
1884        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
1885        // 4MiB
1886        let data: Vec<u64> = (0..500_000).collect();
1887        let array = UInt64Array::from(data);
1888        let batch =
1889            RecordBatch::try_new(arrow_schema.clone().into(), vec![Arc::new(array)]).unwrap();
1890
1891        // 2MiB
1892        unsafe {
1893            std::env::set_var(ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES, "2097152");
1894        }
1895
1896        let options = FileWriterOptions {
1897            max_page_bytes: None, // enforce env
1898            ..Default::default()
1899        };
1900
1901        let path = TempObjFile::default();
1902        let object_store = ObjectStore::local();
1903        let mut writer = FileWriter::try_new(
1904            object_store.create(&path).await.unwrap(),
1905            lance_schema.clone(),
1906            options,
1907        )
1908        .unwrap();
1909
1910        writer.write_batch(&batch).await.unwrap();
1911        writer.finish().await.unwrap();
1912
1913        let fs = FsFixture::default();
1914        let file_scheduler = fs
1915            .scheduler
1916            .open_file(&path, &CachedFileSize::unknown())
1917            .await
1918            .unwrap();
1919        let file_reader = FileReader::try_open(
1920            file_scheduler,
1921            None,
1922            Arc::<DecoderPlugins>::default(),
1923            &LanceCache::no_cache(),
1924            FileReaderOptions::default(),
1925        )
1926        .await
1927        .unwrap();
1928
1929        for col_metadata in file_reader.metadata().column_metadatas.iter() {
1930            for page in col_metadata.pages.iter() {
1931                let total_size: u64 = page.buffer_sizes.iter().sum();
1932                assert!(
1933                    total_size <= 2 * 1024 * 1024,
1934                    "Page size {} exceeds 2MB limit",
1935                    total_size
1936                );
1937            }
1938        }
1939
1940        unsafe {
1941            std::env::set_var(ENV_LANCE_FILE_WRITER_MAX_PAGE_BYTES, "");
1942        }
1943    }
1944
1945    #[tokio::test]
1946    async fn test_compression_overrides_end_to_end() {
1947        // Create test schema with different column types
1948        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1949            ArrowField::new("customer_id", DataType::Int32, false),
1950            ArrowField::new("product_id", DataType::Int32, false),
1951            ArrowField::new("quantity", DataType::Int32, false),
1952            ArrowField::new("price", DataType::Float32, false),
1953            ArrowField::new("description", DataType::Utf8, false),
1954        ]));
1955
1956        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
1957
1958        // Create test data with patterns suitable for different compression
1959        let mut customer_ids = Int32Builder::new();
1960        let mut product_ids = Int32Builder::new();
1961        let mut quantities = Int32Builder::new();
1962        let mut prices = Float32Builder::new();
1963        let mut descriptions = Vec::new();
1964
1965        // Generate data with specific patterns:
1966        // - customer_id: highly repetitive (good for RLE)
1967        // - product_id: moderately repetitive (good for RLE)
1968        // - quantity: random values (not good for RLE)
1969        // - price: some repetition
1970        // - description: long strings (good for Zstd)
1971        for i in 0..10000 {
1972            // Customer ID repeats every 100 rows (100 unique customers)
1973            // This creates runs of 100 identical values
1974            customer_ids.append_value(i / 100);
1975
1976            // Product ID has only 5 unique values with long runs
1977            product_ids.append_value(i / 2000);
1978
1979            // Quantity is mostly 1 with occasional other values
1980            quantities.append_value(if i % 10 == 0 { 5 } else { 1 });
1981
1982            // Price has only 3 unique values
1983            prices.append_value(match i % 3 {
1984                0 => 9.99,
1985                1 => 19.99,
1986                _ => 29.99,
1987            });
1988
1989            // Descriptions are repetitive but we'll keep them simple
1990            descriptions.push(format!("Product {}", i / 2000));
1991        }
1992
1993        let batch = RecordBatch::try_new(
1994            arrow_schema.clone(),
1995            vec![
1996                Arc::new(customer_ids.finish()),
1997                Arc::new(product_ids.finish()),
1998                Arc::new(quantities.finish()),
1999                Arc::new(prices.finish()),
2000                Arc::new(StringArray::from(descriptions)),
2001            ],
2002        )
2003        .unwrap();
2004
2005        // Configure compression parameters
2006        let mut params = CompressionParams::new();
2007
2008        // RLE for ID columns (ends with _id)
2009        params.columns.insert(
2010            "*_id".to_string(),
2011            CompressionFieldParams {
2012                rle_threshold: Some(0.5), // Lower threshold to trigger RLE more easily
2013                compression: None,        // Will use default compression if any
2014                compression_level: None,
2015                bss: Some(lance_encoding::compression_config::BssMode::Off), // Explicitly disable BSS to ensure RLE is used
2016                minichunk_size: None,
2017            },
2018        );
2019
2020        // For now, we'll skip Zstd compression since it's not imported
2021        // In a real implementation, you could add other compression types here
2022
2023        // Build encoding strategy with compression parameters
2024        let encoding_strategy =
2025            super::encoding_strategy_with_params(LanceFileVersion::V2_1, params).unwrap();
2026
2027        // Configure file writer options
2028        let options = FileWriterOptions {
2029            encoding_strategy: Some(encoding_strategy),
2030            format_version: Some(LanceFileVersion::V2_1),
2031            max_page_bytes: Some(64 * 1024), // 64KB pages
2032            ..Default::default()
2033        };
2034
2035        // Write the file
2036        let path = TempObjFile::default();
2037        let object_store = ObjectStore::local();
2038
2039        let mut writer = FileWriter::try_new(
2040            object_store.create(&path).await.unwrap(),
2041            lance_schema.clone(),
2042            options,
2043        )
2044        .unwrap();
2045
2046        writer.write_batch(&batch).await.unwrap();
2047        writer.add_schema_metadata("compression_test", "configured_compression");
2048        writer.finish().await.unwrap();
2049
2050        // Now write the same data without compression overrides for comparison
2051        let path_no_compression = TempObjFile::default();
2052        let default_options = FileWriterOptions {
2053            format_version: Some(LanceFileVersion::V2_1),
2054            max_page_bytes: Some(64 * 1024),
2055            ..Default::default()
2056        };
2057
2058        let mut writer_no_compression = FileWriter::try_new(
2059            object_store.create(&path_no_compression).await.unwrap(),
2060            lance_schema.clone(),
2061            default_options,
2062        )
2063        .unwrap();
2064
2065        writer_no_compression.write_batch(&batch).await.unwrap();
2066        writer_no_compression.finish().await.unwrap();
2067
2068        // Note: With our current data patterns and RLE compression, the compressed file
2069        // might actually be slightly larger due to compression metadata overhead.
2070        // This is expected and the test is mainly to verify the system works end-to-end.
2071
2072        // Read back the compressed file and verify data integrity
2073        let fs = FsFixture::default();
2074        let file_scheduler = fs
2075            .scheduler
2076            .open_file(&path, &CachedFileSize::unknown())
2077            .await
2078            .unwrap();
2079
2080        let file_reader = FileReader::try_open(
2081            file_scheduler,
2082            None,
2083            Arc::<DecoderPlugins>::default(),
2084            &LanceCache::no_cache(),
2085            FileReaderOptions::default(),
2086        )
2087        .await
2088        .unwrap();
2089
2090        // Verify metadata
2091        let metadata = file_reader.metadata();
2092        assert_eq!(metadata.major_version, 2);
2093        assert_eq!(metadata.minor_version, 1);
2094
2095        let schema = file_reader.schema();
2096        assert_eq!(
2097            schema.metadata.get("compression_test"),
2098            Some(&"configured_compression".to_string())
2099        );
2100
2101        // Verify the actual encodings used
2102        let column_metadatas = &metadata.column_metadatas;
2103
2104        // Check customer_id column (index 0) - should use RLE due to our configuration
2105        assert!(!column_metadatas[0].pages.is_empty());
2106        let customer_id_encoding = describe_encoding(&column_metadatas[0].pages[0]);
2107        assert!(
2108            customer_id_encoding.contains("RLE") || customer_id_encoding.contains("Rle"),
2109            "customer_id column should use RLE encoding due to '*_id' pattern match, but got: {}",
2110            customer_id_encoding
2111        );
2112
2113        // Check product_id column (index 1) - should use RLE due to our configuration
2114        assert!(!column_metadatas[1].pages.is_empty());
2115        let product_id_encoding = describe_encoding(&column_metadatas[1].pages[0]);
2116        assert!(
2117            product_id_encoding.contains("RLE") || product_id_encoding.contains("Rle"),
2118            "product_id column should use RLE encoding due to '*_id' pattern match, but got: {}",
2119            product_id_encoding
2120        );
2121    }
2122
2123    #[tokio::test]
2124    async fn test_field_metadata_compression() {
2125        // Test that field metadata compression settings are respected
2126        let mut metadata = HashMap::new();
2127        metadata.insert(
2128            lance_encoding::constants::COMPRESSION_META_KEY.to_string(),
2129            "zstd".to_string(),
2130        );
2131        metadata.insert(
2132            lance_encoding::constants::COMPRESSION_LEVEL_META_KEY.to_string(),
2133            "6".to_string(),
2134        );
2135
2136        let arrow_schema = Arc::new(ArrowSchema::new(vec![
2137            ArrowField::new("id", DataType::Int32, false),
2138            ArrowField::new("text", DataType::Utf8, false).with_metadata(metadata.clone()),
2139            ArrowField::new("data", DataType::Int32, false).with_metadata(HashMap::from([(
2140                lance_encoding::constants::COMPRESSION_META_KEY.to_string(),
2141                "none".to_string(),
2142            )])),
2143        ]));
2144
2145        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
2146
2147        // Create test data
2148        let id_array = Int32Array::from_iter_values(0..1000);
2149        let text_array = StringArray::from_iter_values(
2150            (0..1000).map(|i| format!("test string {} repeated text", i)),
2151        );
2152        let data_array = Int32Array::from_iter_values((0..1000).map(|i| i * 2));
2153
2154        let batch = RecordBatch::try_new(
2155            arrow_schema.clone(),
2156            vec![
2157                Arc::new(id_array),
2158                Arc::new(text_array),
2159                Arc::new(data_array),
2160            ],
2161        )
2162        .unwrap();
2163
2164        let path = TempObjFile::default();
2165        let object_store = ObjectStore::local();
2166
2167        // Create encoding strategy that will read from field metadata
2168        let params = CompressionParams::new();
2169        let encoding_strategy =
2170            super::encoding_strategy_with_params(LanceFileVersion::V2_1, params).unwrap();
2171
2172        let options = FileWriterOptions {
2173            encoding_strategy: Some(encoding_strategy),
2174            format_version: Some(LanceFileVersion::V2_1),
2175            ..Default::default()
2176        };
2177        let mut writer = FileWriter::try_new(
2178            object_store.create(&path).await.unwrap(),
2179            lance_schema.clone(),
2180            options,
2181        )
2182        .unwrap();
2183
2184        writer.write_batch(&batch).await.unwrap();
2185        writer.finish().await.unwrap();
2186
2187        // Read back metadata
2188        let fs = FsFixture::default();
2189        let file_scheduler = fs
2190            .scheduler
2191            .open_file(&path, &CachedFileSize::unknown())
2192            .await
2193            .unwrap();
2194        let file_reader = FileReader::try_open(
2195            file_scheduler,
2196            None,
2197            Arc::<DecoderPlugins>::default(),
2198            &LanceCache::no_cache(),
2199            FileReaderOptions::default(),
2200        )
2201        .await
2202        .unwrap();
2203
2204        let column_metadatas = &file_reader.metadata().column_metadatas;
2205
2206        // The text column (index 1) should use zstd compression based on metadata
2207        let text_encoding = describe_encoding(&column_metadatas[1].pages[0]);
2208        // For string columns, we expect Binary encoding with zstd compression
2209        assert!(
2210            text_encoding.contains("Zstd"),
2211            "text column should use zstd compression from field metadata, but got: {}",
2212            text_encoding
2213        );
2214
2215        // The data column (index 2) should use no compression based on metadata
2216        let data_encoding = describe_encoding(&column_metadatas[2].pages[0]);
2217        // For Int32 columns with "none" compression, we expect Flat encoding without compression
2218        assert!(
2219            data_encoding.contains("Flat") && data_encoding.contains("compression: None"),
2220            "data column should use no compression from field metadata, but got: {}",
2221            data_encoding
2222        );
2223    }
2224
2225    #[tokio::test]
2226    async fn test_field_metadata_rle_threshold() {
2227        // Test that RLE threshold from field metadata is respected
2228        let mut metadata = HashMap::new();
2229        metadata.insert(
2230            lance_encoding::constants::RLE_THRESHOLD_META_KEY.to_string(),
2231            "0.9".to_string(),
2232        );
2233        // Also set compression to ensure RLE is used
2234        metadata.insert(
2235            lance_encoding::constants::COMPRESSION_META_KEY.to_string(),
2236            "lz4".to_string(),
2237        );
2238        // Explicitly disable BSS to ensure RLE is tested
2239        metadata.insert(
2240            lance_encoding::constants::BSS_META_KEY.to_string(),
2241            "off".to_string(),
2242        );
2243
2244        let arrow_schema = Arc::new(ArrowSchema::new(vec![
2245            ArrowField::new("status", DataType::Int32, false).with_metadata(metadata),
2246        ]));
2247
2248        let lance_schema = LanceSchema::try_from(arrow_schema.as_ref()).unwrap();
2249
2250        // Create data with very high repetition (3 runs for 10000 values = 0.0003 ratio)
2251        let status_array = Int32Array::from_iter_values(
2252            std::iter::repeat_n(200, 8000)
2253                .chain(std::iter::repeat_n(404, 1500))
2254                .chain(std::iter::repeat_n(500, 500)),
2255        );
2256
2257        let batch =
2258            RecordBatch::try_new(arrow_schema.clone(), vec![Arc::new(status_array)]).unwrap();
2259
2260        let path = TempObjFile::default();
2261        let object_store = ObjectStore::local();
2262
2263        // Create encoding strategy that will read from field metadata
2264        let params = CompressionParams::new();
2265        let encoding_strategy =
2266            super::encoding_strategy_with_params(LanceFileVersion::V2_1, params).unwrap();
2267
2268        let options = FileWriterOptions {
2269            encoding_strategy: Some(encoding_strategy),
2270            format_version: Some(LanceFileVersion::V2_1),
2271            ..Default::default()
2272        };
2273        let mut writer = FileWriter::try_new(
2274            object_store.create(&path).await.unwrap(),
2275            lance_schema.clone(),
2276            options,
2277        )
2278        .unwrap();
2279
2280        writer.write_batch(&batch).await.unwrap();
2281        writer.finish().await.unwrap();
2282
2283        // Read back and check encoding
2284        let fs = FsFixture::default();
2285        let file_scheduler = fs
2286            .scheduler
2287            .open_file(&path, &CachedFileSize::unknown())
2288            .await
2289            .unwrap();
2290        let file_reader = FileReader::try_open(
2291            file_scheduler,
2292            None,
2293            Arc::<DecoderPlugins>::default(),
2294            &LanceCache::no_cache(),
2295            FileReaderOptions::default(),
2296        )
2297        .await
2298        .unwrap();
2299
2300        let column_metadatas = &file_reader.metadata().column_metadatas;
2301        let status_encoding = describe_encoding(&column_metadatas[0].pages[0]);
2302        assert!(
2303            status_encoding.contains("RLE") || status_encoding.contains("Rle"),
2304            "status column should use RLE encoding due to metadata threshold, but got: {}",
2305            status_encoding
2306        );
2307    }
2308
2309    #[tokio::test]
2310    async fn test_large_page_split_on_read() {
2311        use arrow_array::Array;
2312        use futures::TryStreamExt;
2313        use lance_encoding::decoder::FilterExpression;
2314        use lance_io::ReadBatchParams;
2315
2316        // Test that large pages written with relaxed limits can be split during read
2317
2318        let arrow_field = ArrowField::new("data", DataType::Binary, false);
2319        let arrow_schema = ArrowSchema::new(vec![arrow_field]);
2320        let lance_schema = LanceSchema::try_from(&arrow_schema).unwrap();
2321
2322        // Create a large binary value (40MB) to trigger large page creation
2323        let large_value = vec![42u8; 40 * 1024 * 1024];
2324        let array = arrow_array::BinaryArray::from(vec![
2325            Some(large_value.as_slice()),
2326            Some(b"small value"),
2327        ]);
2328        let batch = RecordBatch::try_new(Arc::new(arrow_schema), vec![Arc::new(array)]).unwrap();
2329
2330        // Write with relaxed page size limit (128MB)
2331        let options = FileWriterOptions {
2332            max_page_bytes: Some(128 * 1024 * 1024),
2333            format_version: Some(LanceFileVersion::V2_1),
2334            ..Default::default()
2335        };
2336
2337        let fs = FsFixture::default();
2338        let path = fs.tmp_path;
2339
2340        let mut writer = FileWriter::try_new(
2341            fs.object_store.create(&path).await.unwrap(),
2342            lance_schema.clone(),
2343            options,
2344        )
2345        .unwrap();
2346
2347        writer.write_batch(&batch).await.unwrap();
2348        let write_summary = writer.finish().await.unwrap();
2349        assert_eq!(write_summary.num_rows, 2);
2350        assert_eq!(
2351            write_summary.size_bytes,
2352            fs.object_store.size(&path).await.unwrap()
2353        );
2354
2355        // Read back with split configuration
2356        let file_scheduler = fs
2357            .scheduler
2358            .open_file(&path, &CachedFileSize::unknown())
2359            .await
2360            .unwrap();
2361
2362        // Configure reader to split pages larger than 10MB into chunks
2363        let reader_options = FileReaderOptions {
2364            read_chunk_size: 10 * 1024 * 1024, // 10MB chunks
2365            ..Default::default()
2366        };
2367
2368        let file_reader = FileReader::try_open(
2369            file_scheduler,
2370            None,
2371            Arc::<DecoderPlugins>::default(),
2372            &LanceCache::no_cache(),
2373            reader_options,
2374        )
2375        .await
2376        .unwrap();
2377
2378        // Read the data back
2379        let stream = file_reader
2380            .read_stream(
2381                ReadBatchParams::RangeFull,
2382                1024,
2383                10, // batch_readahead
2384                FilterExpression::no_filter(),
2385            )
2386            .await
2387            .unwrap();
2388
2389        let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
2390        assert_eq!(batches.len(), 1);
2391
2392        // Verify the data is correctly read despite splitting
2393        let read_array = batches[0].column(0);
2394        let read_binary = read_array
2395            .as_any()
2396            .downcast_ref::<arrow_array::BinaryArray>()
2397            .unwrap();
2398
2399        assert_eq!(read_binary.len(), 2);
2400        assert_eq!(read_binary.value(0).len(), 40 * 1024 * 1024);
2401        assert_eq!(read_binary.value(1), b"small value");
2402
2403        // Verify first value matches what we wrote
2404        assert!(read_binary.value(0).iter().all(|&b| b == 42u8));
2405    }
2406
2407    fn spill_config() -> (TempObjFile, Arc<ObjectStore>) {
2408        let spill_path = TempObjFile::default();
2409        (spill_path, Arc::new(ObjectStore::local()))
2410    }
2411
2412    fn make_batches(num_batches: i32, num_cols: usize, rows_per_batch: i32) -> Vec<RecordBatch> {
2413        let fields: Vec<_> = (0..num_cols)
2414            .map(|c| ArrowField::new(format!("c{c}"), DataType::Int32, false))
2415            .collect();
2416        let schema = Arc::new(ArrowSchema::new(fields));
2417        (0..num_batches)
2418            .map(|i| {
2419                let cols: Vec<Arc<dyn arrow_array::Array>> = (0..num_cols)
2420                    .map(|c| {
2421                        let start = (i * rows_per_batch + c as i32) * 100;
2422                        Arc::new(Int32Array::from_iter_values(start..start + rows_per_batch))
2423                            as Arc<dyn arrow_array::Array>
2424                    })
2425                    .collect();
2426                RecordBatch::try_new(schema.clone(), cols).unwrap()
2427            })
2428            .collect()
2429    }
2430
2431    async fn write_and_read_batches(
2432        batches: &[RecordBatch],
2433        spill: Option<(Arc<ObjectStore>, object_store::path::Path)>,
2434    ) -> Vec<RecordBatch> {
2435        let fs = FsFixture::default();
2436        let lance_schema = LanceSchema::try_from(batches[0].schema().as_ref()).unwrap();
2437        let writer = fs.object_store.create(&fs.tmp_path).await.unwrap();
2438        let mut file_writer =
2439            FileWriter::try_new(writer, lance_schema, FileWriterOptions::default()).unwrap();
2440        if let Some((store, path)) = spill {
2441            file_writer = file_writer.with_page_metadata_spill(store, path);
2442        }
2443        for batch in batches {
2444            file_writer.write_batch(batch).await.unwrap();
2445        }
2446        file_writer.add_schema_metadata("foo", "bar");
2447        file_writer.finish().await.unwrap();
2448
2449        crate::testing::read_lance_file(
2450            &fs,
2451            Arc::<DecoderPlugins>::default(),
2452            lance_encoding::decoder::FilterExpression::no_filter(),
2453        )
2454        .await
2455    }
2456
2457    #[rstest::rstest]
2458    #[case::multi_col(20, 2, 100)]
2459    #[case::many_batches(50, 2, 100)]
2460    #[tokio::test]
2461    async fn test_page_metadata_spill_roundtrip(
2462        #[case] num_batches: i32,
2463        #[case] num_cols: usize,
2464        #[case] rows_per_batch: i32,
2465    ) {
2466        let batches = make_batches(num_batches, num_cols, rows_per_batch);
2467        let baseline = write_and_read_batches(&batches, None).await;
2468        let (spill_path, spill_store) = spill_config();
2469        let spilled =
2470            write_and_read_batches(&batches, Some((spill_store, spill_path.as_ref().clone())))
2471                .await;
2472        assert_eq!(baseline, spilled);
2473    }
2474
2475    #[tokio::test]
2476    async fn test_page_metadata_spill_many_columns() {
2477        // Many columns forces small per-column buffer limits, exercising mid-write flushing.
2478        let batches = make_batches(10, 500, 100);
2479        let baseline = write_and_read_batches(&batches, None).await;
2480        let (spill_path, spill_store) = spill_config();
2481        let spilled =
2482            write_and_read_batches(&batches, Some((spill_store, spill_path.as_ref().clone())))
2483                .await;
2484        assert_eq!(baseline, spilled);
2485    }
2486}