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