Skip to main content

lance_file/
concat.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Concatenation of complete encoded Lance files.
5//!
6//! This module owns compatibility checks and metadata relocation for copying
7//! already-encoded pages into a new ordinary Lance file. Callers retain
8//! responsibility for dataset-level grouping, transactions, and fallbacks.
9
10use std::{
11    collections::{BTreeMap, BTreeSet, VecDeque},
12    fmt,
13    future::Future,
14    ops::Range,
15    sync::Arc,
16};
17
18use arrow_array::{Array, ArrayRef, cast::AsArray, types::UInt8Type};
19use arrow_schema::{DataType as ArrowDataType, Field as ArrowField};
20use futures::TryStreamExt;
21use lance_arrow::FieldExt;
22use lance_core::{
23    Error, Result,
24    cache::LanceCache,
25    datatypes::{BLOB_V2_DESC_LANCE_FIELD, BlobHandling, BlobKind, Field, Schema},
26};
27use lance_encoding::decoder::{ColumnInfo, DecoderPlugins, FilterExpression, PageInfo};
28use lance_io::{ReadBatchParams, scheduler::FileScheduler, traits::Writer as ObjectWriter};
29use prost::Message;
30use prost_types::Any;
31
32use crate::{
33    reader::{CachedFileMetadata, FileReader, RawFileMetadataOpen},
34    version::ConcreteFileVersion,
35    versions,
36    writer::{FileWriteSummary, FileWriterOptions},
37};
38
39/// Caller-defined runtime identity of the final target for Blob-bearing parts.
40///
41/// Lance only compares this value when assembling data-file parts. It does not
42/// interpret it as a dataset, base, or object-store identity, persist it, or
43/// define a recovery protocol for it. The caller must provide the same identity
44/// for every part and target that belong to one assembly operation.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct BlobTargetId(Arc<str>);
47
48impl BlobTargetId {
49    /// Create an opaque Blob target identity.
50    pub fn new(identity: impl Into<Arc<str>>) -> Self {
51        Self(identity.into())
52    }
53
54    /// Return the caller-defined target identity.
55    pub fn as_str(&self) -> &str {
56        self.0.as_ref()
57    }
58}
59
60/// One complete immutable Lance file supplied to [`concat_files`].
61#[derive(Clone)]
62pub struct EncodedFileInput {
63    scheduler: FileScheduler,
64    expected_num_rows: Option<u64>,
65}
66
67impl EncodedFileInput {
68    /// Create an input from an already-open file scheduler.
69    pub fn new(scheduler: FileScheduler) -> Self {
70        Self {
71            scheduler,
72            expected_num_rows: None,
73        }
74    }
75
76    /// Require the file metadata to report this physical row count.
77    ///
78    /// A mismatch is an input error, not a compatibility result.
79    pub fn with_expected_num_rows(mut self, expected_num_rows: u64) -> Self {
80        self.expected_num_rows = Some(expected_num_rows);
81        self
82    }
83
84    /// The path used to read this input.
85    pub fn path(&self) -> &object_store::path::Path {
86        self.scheduler.reader().path()
87    }
88
89    fn scheduler(&self) -> FileScheduler {
90        self.scheduler.clone()
91    }
92}
93
94/// A complete ordinary Lance file validated for data-file concatenation.
95///
96/// A part is independently readable and is not an incomplete file-format
97/// fragment or a persisted Manifest entity. Opening one reads its real footer,
98/// verifies that its physical columns form a complete rectangular file, and
99/// checks Blob v2 descriptors against the caller-provided ID lease. Parts with
100/// Blob v2 columns are also bound to the caller-provided [`BlobTargetId`].
101///
102/// This runtime value has no fragment, source-row, range, dataset, or storage
103/// identity. Lance defines no serialization or recovery contract for it. The
104/// caller owns the input storage and must keep every Blob-bearing part associated
105/// with the dataset and base where its managed payloads were written. The order
106/// passed to [`concat_data_file_parts`] determines final physical row order.
107///
108/// # Example
109///
110/// ```
111/// use lance_file::concat::{DataFilePart, EncodedFileInput};
112/// use lance_io::scheduler::FileScheduler;
113///
114/// # async fn open_part(file: FileScheduler) -> lance_core::Result<()> {
115/// let part = DataFilePart::open(EncodedFileInput::new(file), None, None).await?;
116/// println!("part rows: {}", part.num_rows());
117/// # Ok(())
118/// # }
119/// ```
120#[derive(Clone)]
121pub struct DataFilePart {
122    input: EncodedFileInput,
123    metadata: Arc<CachedFileMetadata>,
124    schema: Arc<Schema>,
125    blob_ids: Option<Range<u32>>,
126    blob_target_id: Option<BlobTargetId>,
127}
128
129impl fmt::Debug for DataFilePart {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.debug_struct("DataFilePart")
132            .field("path", &self.input.path())
133            .field("version", &self.metadata.version)
134            .field("num_rows", &self.metadata.num_rows)
135            .field("blob_ids", &self.blob_ids)
136            .field("blob_target_id", &self.blob_target_id)
137            .finish()
138    }
139}
140
141impl DataFilePart {
142    /// Open and validate one complete encoded file.
143    ///
144    /// `blob_ids` is the half-open ID range leased to this part. Managed
145    /// Packed and Dedicated descriptors must fall inside it. `blob_target_id`
146    /// associates the part with one final target for runtime equality checks and
147    /// is required whenever the part contains Blob v2 columns. It does not prove
148    /// that the target belongs to a particular dataset or storage namespace.
149    /// Non-empty Inline Blob v2 descriptors and legacy Blob v1 columns are
150    /// rejected because their payload locations cannot be reused in a different
151    /// data file.
152    pub async fn open(
153        input: EncodedFileInput,
154        blob_ids: Option<Range<u32>>,
155        blob_target_id: Option<BlobTargetId>,
156    ) -> Result<Self> {
157        validate_blob_id_range(blob_ids.as_ref())?;
158        let metadata = Arc::new(FileReader::read_all_metadata(&input.scheduler()).await?);
159        let schema = Arc::new(normalize_blob_footer_schema(metadata.file_schema.as_ref()));
160        if let Some(expected_num_rows) = input.expected_num_rows
161            && metadata.num_rows != expected_num_rows
162        {
163            return Err(Error::invalid_input(format!(
164                "part at '{}' has {} physical rows but {} were expected",
165                input.path(),
166                metadata.num_rows,
167                expected_num_rows
168            )));
169        }
170        let has_blob_v1 = schema
171            .fields_pre_order()
172            .any(|field| field.is_blob() && !field.is_blob_v2());
173        if has_blob_v1 {
174            return Err(Error::not_supported(format!(
175                "part at '{}' contains legacy Blob v1 columns",
176                input.path()
177            )));
178        }
179        let validation_schema = descriptor_projection_schema(schema.as_ref());
180        let normalized_rows = versions::validate_external_metadata(
181            metadata.version,
182            &validation_schema,
183            metadata.as_ref(),
184        )
185        .map_err(|error| {
186            Error::corrupt_file(
187                input.path().clone(),
188                format!("part has incomplete file metadata: {error}"),
189            )
190        })?;
191        if normalized_rows != metadata.num_rows {
192            return Err(Error::corrupt_file(
193                input.path().clone(),
194                format!(
195                    "part descriptor reports {} physical rows but its columns normalize to {normalized_rows}",
196                    metadata.num_rows
197                ),
198            ));
199        }
200
201        let has_blob_v2 = schema.fields_pre_order().any(|field| field.is_blob_v2());
202        if has_blob_v2 {
203            validate_blob_descriptors(
204                &input,
205                metadata.as_ref(),
206                schema.as_ref(),
207                blob_ids.as_ref(),
208            )
209            .await?;
210            if blob_target_id.is_none() {
211                return Err(Error::invalid_input(format!(
212                    "part at '{}' contains Blob v2 columns but no Blob target ID was provided",
213                    input.path()
214                )));
215            }
216        }
217
218        Ok(Self {
219            input,
220            metadata,
221            schema,
222            blob_ids,
223            blob_target_id,
224        })
225    }
226
227    /// Number of physical rows described by the part footer.
228    pub fn num_rows(&self) -> u64 {
229        self.metadata.num_rows
230    }
231}
232
233fn descriptor_projection_schema(schema: &Schema) -> Schema {
234    let mut projected = schema.clone();
235    projected.fields = projected
236        .fields
237        .into_iter()
238        .map(|field| BlobHandling::BlobsDescriptions.unload_if_needed(field))
239        .collect();
240    projected
241}
242
243fn descriptor_child_matches(field: &Field, expected: &Field) -> bool {
244    field.id == -1
245        && field.parent_id == -1
246        && field.name == expected.name
247        && field.logical_type == expected.logical_type
248        && field.children.is_empty()
249}
250
251fn attach_blob_descriptor_children(
252    fields: &mut [Field],
253    descriptor_children: &mut VecDeque<Vec<Field>>,
254) {
255    for field in fields {
256        if field.is_blob() && field.children.is_empty() {
257            if let Some(children) = descriptor_children.pop_front() {
258                field.children = children;
259            }
260        } else {
261            attach_blob_descriptor_children(&mut field.children, descriptor_children);
262        }
263    }
264}
265
266/// Blob descriptor children historically use anonymous field IDs in the file
267/// descriptor. Reconstruct their tree shape before applying ordinary schema
268/// projection rules. This interprets the existing footer representation and
269/// does not add persisted metadata or alter the file grammar.
270fn normalize_blob_footer_schema(schema: &Schema) -> Schema {
271    let expected = &BLOB_V2_DESC_LANCE_FIELD.children;
272    let missing_descriptor_count = schema
273        .fields_pre_order()
274        .filter(|field| field.is_blob() && field.children.is_empty())
275        .count();
276    if missing_descriptor_count == 0 {
277        return schema.clone();
278    }
279    let mut normalized = schema.clone();
280    let mut descriptor_children = VecDeque::new();
281    let mut field_index = 0;
282    while descriptor_children.len() < missing_descriptor_count
283        && field_index + expected.len() <= normalized.fields.len()
284    {
285        if normalized.fields[field_index..field_index + expected.len()]
286            .iter()
287            .zip(expected)
288            .all(|(field, expected)| descriptor_child_matches(field, expected))
289        {
290            descriptor_children.push_back(
291                normalized
292                    .fields
293                    .drain(field_index..field_index + expected.len())
294                    .collect(),
295            );
296        } else {
297            field_index += 1;
298        }
299    }
300    attach_blob_descriptor_children(&mut normalized.fields, &mut descriptor_children);
301    normalized
302}
303
304fn validate_blob_id_range(blob_ids: Option<&Range<u32>>) -> Result<()> {
305    if let Some(blob_ids) = blob_ids
306        && (blob_ids.start == 0 || blob_ids.start >= blob_ids.end)
307    {
308        return Err(Error::invalid_input(format!(
309            "part Blob ID range must be non-empty and start at 1 or greater, got {}..{}",
310            blob_ids.start, blob_ids.end
311        )));
312    }
313    Ok(())
314}
315
316async fn validate_blob_descriptors(
317    input: &EncodedFileInput,
318    metadata: &CachedFileMetadata,
319    schema: &Schema,
320    blob_ids: Option<&Range<u32>>,
321) -> Result<()> {
322    let projected_schema = descriptor_projection_schema(schema);
323    let blob_field_ids = projected_schema
324        .fields_pre_order()
325        .filter(|field| field.is_blob_v2())
326        .map(|field| field.id)
327        .collect::<Vec<_>>();
328    let unique_blob_field_ids = blob_field_ids.iter().copied().collect::<BTreeSet<_>>();
329    if unique_blob_field_ids.len() != blob_field_ids.len()
330        || unique_blob_field_ids
331            .first()
332            .is_some_and(|field_id| *field_id < 0)
333    {
334        return Err(Error::corrupt_file(
335            input.path().clone(),
336            "Blob v2 fields in a data-file part must have unique non-negative field IDs",
337        ));
338    }
339    let blob_schema = projected_schema.project_by_ids(&blob_field_ids, true);
340    let (field_ids, column_indices) =
341        versions::data_file_columns(metadata.version, &projected_schema);
342    let field_id_to_column_index = field_ids
343        .into_iter()
344        .zip(column_indices)
345        .filter_map(|(field_id, column_index)| {
346            (field_id >= 0 && column_index >= 0).then_some((field_id as u32, column_index as u32))
347        })
348        .collect::<BTreeMap<_, _>>();
349    let projection = versions::reader_projection_from_field_ids(
350        metadata.version,
351        &blob_schema,
352        &field_id_to_column_index,
353    )?;
354    let reader = FileReader::try_open(
355        input.scheduler(),
356        Some(projection),
357        Arc::<DecoderPlugins>::default(),
358        &LanceCache::no_cache(),
359        Default::default(),
360    )
361    .await?;
362    let mut batches = reader
363        .read_stream(
364            ReadBatchParams::RangeFull,
365            8192,
366            4,
367            FilterExpression::no_filter(),
368        )
369        .await?;
370    while let Some(batch) = batches.try_next().await? {
371        let selected = vec![true; batch.num_rows()];
372        for (field, array) in batch.schema().fields().iter().zip(batch.columns()) {
373            validate_blob_field(field.as_ref(), array, &selected, blob_ids, input.path())?;
374        }
375    }
376    Ok(())
377}
378
379fn validate_blob_field(
380    field: &ArrowField,
381    array: &ArrayRef,
382    selected: &[bool],
383    blob_ids: Option<&Range<u32>>,
384    path: &object_store::path::Path,
385) -> Result<()> {
386    if field.is_blob() {
387        let descriptors = array.as_struct();
388        let kinds = descriptors
389            .column_by_name("kind")
390            .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no kind"))?
391            .as_primitive::<UInt8Type>();
392        let positions = descriptors
393            .column_by_name("position")
394            .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no position"))?
395            .as_primitive::<arrow_array::types::UInt64Type>();
396        let sizes = descriptors
397            .column_by_name("size")
398            .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no size"))?
399            .as_primitive::<arrow_array::types::UInt64Type>();
400        let ids = descriptors
401            .column_by_name("blob_id")
402            .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no blob_id"))?
403            .as_primitive::<arrow_array::types::UInt32Type>();
404        for (row, is_selected) in selected.iter().copied().enumerate() {
405            if !is_selected || descriptors.is_null(row) {
406                continue;
407            }
408            let kind = BlobKind::try_from(kinds.value(row))?;
409            match kind {
410                BlobKind::Inline if sizes.value(row) > 0 => {
411                    return Err(Error::invalid_input(format!(
412                        "part at '{}' contains a non-empty Inline Blob v2 descriptor at row {row}; data-file part concatenation requires Packed or Dedicated storage",
413                        path
414                    )));
415                }
416                BlobKind::Packed | BlobKind::Dedicated => {
417                    let blob_id = ids.value(row);
418                    let Some(blob_ids) = blob_ids else {
419                        return Err(Error::invalid_input(format!(
420                            "part at '{}' contains managed Blob ID {blob_id} at row {row} but no Blob ID range was provided",
421                            path
422                        )));
423                    };
424                    if !blob_ids.contains(&blob_id) {
425                        return Err(Error::invalid_input(format!(
426                            "part at '{}' contains managed Blob ID {blob_id} at row {row}, outside declared range {}..{}",
427                            path, blob_ids.start, blob_ids.end
428                        )));
429                    }
430                    if kind == BlobKind::Dedicated && positions.value(row) != 0 {
431                        return Err(Error::corrupt_file(
432                            path.clone(),
433                            format!(
434                                "Dedicated Blob descriptor at row {row} has non-zero position {}",
435                                positions.value(row)
436                            ),
437                        ));
438                    }
439                }
440                BlobKind::Managed | BlobKind::ManagedWithBase => {
441                    let uris = descriptors
442                        .column_by_name("blob_uri")
443                        .ok_or_else(|| {
444                            Error::corrupt_file(path.clone(), "Managed descriptor has no blob_uri")
445                        })?
446                        .as_string::<i32>();
447                    lance_core::utils::blob::validate_managed_reference(
448                        uris.value(row),
449                        positions.value(row),
450                        sizes.value(row),
451                    )?;
452                }
453                BlobKind::Inline | BlobKind::External => {}
454            }
455        }
456        return Ok(());
457    }
458
459    match field.data_type() {
460        ArrowDataType::Struct(children) => {
461            let struct_array = array.as_struct();
462            let child_selected = selected
463                .iter()
464                .copied()
465                .enumerate()
466                .map(|(row, is_selected)| is_selected && struct_array.is_valid(row))
467                .collect::<Vec<_>>();
468            for (child, child_array) in children.iter().zip(struct_array.columns()) {
469                validate_blob_field(child.as_ref(), child_array, &child_selected, blob_ids, path)?;
470            }
471        }
472        ArrowDataType::List(child) => {
473            let list = array.as_list::<i32>();
474            let mut child_selected = vec![false; list.values().len()];
475            for (row, is_selected) in selected.iter().copied().enumerate() {
476                if is_selected && list.is_valid(row) {
477                    let start = list.value_offsets()[row] as usize;
478                    let end = list.value_offsets()[row + 1] as usize;
479                    child_selected[start..end].fill(true);
480                }
481            }
482            validate_blob_field(
483                child.as_ref(),
484                list.values(),
485                &child_selected,
486                blob_ids,
487                path,
488            )?;
489        }
490        ArrowDataType::LargeList(child) => {
491            let list = array.as_list::<i64>();
492            let mut child_selected = vec![false; list.values().len()];
493            for (row, is_selected) in selected.iter().copied().enumerate() {
494                if is_selected && list.is_valid(row) {
495                    let start = list.value_offsets()[row] as usize;
496                    let end = list.value_offsets()[row + 1] as usize;
497                    child_selected[start..end].fill(true);
498                }
499            }
500            validate_blob_field(
501                child.as_ref(),
502                list.values(),
503                &child_selected,
504                blob_ids,
505                path,
506            )?;
507        }
508        _ => {}
509    }
510    Ok(())
511}
512
513/// The exact file grammar and schema required for concatenated output.
514#[derive(Debug, Clone)]
515pub struct FileConcatTarget {
516    /// Exact output grammar. Release aliases are resolved before this boundary.
517    pub version: ConcreteFileVersion,
518    /// Complete schema stored in every input and regenerated in the output.
519    pub schema: Arc<Schema>,
520    blob_target_id: Option<BlobTargetId>,
521}
522
523impl FileConcatTarget {
524    /// Create a concatenation target.
525    pub fn new(version: ConcreteFileVersion, schema: Arc<Schema>) -> Self {
526        Self {
527            version,
528            schema,
529            blob_target_id: None,
530        }
531    }
532
533    /// Bind Blob-bearing parts to one caller-defined final target.
534    pub fn with_blob_target_id(mut self, blob_target_id: BlobTargetId) -> Self {
535        self.blob_target_id = Some(blob_target_id);
536        self
537    }
538}
539
540/// Runtime controls for encoded-file concatenation.
541#[derive(Debug, Clone)]
542pub struct FileConcatOptions {
543    /// Maximum page-buffer bytes requested in one read batch.
544    pub read_batch_bytes: usize,
545    /// Options passed to the exact-version footer writer.
546    pub writer_options: FileWriterOptions,
547}
548
549impl Default for FileConcatOptions {
550    fn default() -> Self {
551        Self {
552            read_batch_bytes: 16 * 1024 * 1024,
553            writer_options: FileWriterOptions::default(),
554        }
555    }
556}
557
558/// Metadata describing the complete file represented by a concat result.
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub struct FileConcatOutput {
561    /// Exact grammar of the completed or reused file.
562    pub version: ConcreteFileVersion,
563    /// Total physical rows in input order.
564    pub num_rows: u64,
565    /// Size of the completed or reused object.
566    pub size_bytes: u64,
567}
568
569/// A compatibility reason that requires a caller-controlled decode/re-encode fallback.
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub enum FileConcatReason {
572    /// Lance v1 does not support encoded-file concatenation.
573    LegacyVersion,
574    /// An input uses a different exact grammar than the target.
575    VersionMismatch {
576        /// Zero-based input position.
577        input_index: usize,
578        /// Version found in the file footer.
579        actual: ConcreteFileVersion,
580        /// Version requested by the target.
581        expected: ConcreteFileVersion,
582    },
583    /// An input's persisted schema differs from the target schema.
584    SchemaMismatch {
585        /// Zero-based input position.
586        input_index: usize,
587    },
588    /// Inputs do not describe the same physical columns.
589    ColumnLayoutMismatch {
590        /// Zero-based input position.
591        input_index: usize,
592        /// Zero-based physical column when one could be identified.
593        column_index: Option<usize>,
594    },
595    /// A column-level encoding cannot safely combine its buffers.
596    ColumnEncodingMismatch {
597        /// Zero-based input position.
598        input_index: usize,
599        /// Zero-based physical column.
600        column_index: usize,
601    },
602    /// A column uses file-level buffers whose page references cannot be relocated.
603    ColumnBuffers {
604        /// Zero-based input position.
605        input_index: usize,
606        /// Zero-based physical column.
607        column_index: usize,
608        /// Number of column buffers referenced by the column metadata.
609        count: usize,
610    },
611    /// A file contains global buffers whose relocation semantics are not defined.
612    ExtraGlobalBuffers {
613        /// Zero-based input position.
614        input_index: usize,
615        /// Number of global buffers, including the schema descriptor.
616        count: usize,
617    },
618    /// The schema contains offsets into external blob storage.
619    BlobColumns,
620}
621
622impl fmt::Display for FileConcatReason {
623    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624        match self {
625            Self::LegacyVersion => f.write_str("Lance v1 files cannot be concatenated"),
626            Self::VersionMismatch {
627                input_index,
628                actual,
629                expected,
630            } => write!(
631                f,
632                "input {input_index} has file version {actual}, expected {expected}"
633            ),
634            Self::SchemaMismatch { input_index } => {
635                write!(f, "input {input_index} has a different file schema")
636            }
637            Self::ColumnLayoutMismatch {
638                input_index,
639                column_index,
640            } => match column_index {
641                Some(column_index) => write!(
642                    f,
643                    "input {input_index} has a different layout for physical column {column_index}"
644                ),
645                None => write!(
646                    f,
647                    "input {input_index} has a different physical column count"
648                ),
649            },
650            Self::ColumnEncodingMismatch {
651                input_index,
652                column_index,
653            } => write!(
654                f,
655                "input {input_index} has an incompatible encoding for physical column {column_index}"
656            ),
657            Self::ColumnBuffers {
658                input_index,
659                column_index,
660                count,
661            } => write!(
662                f,
663                "input {input_index} physical column {column_index} has {count} column buffers whose references cannot be relocated"
664            ),
665            Self::ExtraGlobalBuffers { input_index, count } => write!(
666                f,
667                "input {input_index} has {count} global buffers; only the schema descriptor is supported"
668            ),
669            Self::BlobColumns => {
670                f.write_str("schemas containing blob columns cannot be concatenated")
671            }
672        }
673    }
674}
675
676/// Result of one encoded-file concatenation attempt.
677#[derive(Debug, Clone, PartialEq, Eq)]
678pub enum FileConcatResult {
679    /// A new ordinary Lance file was written.
680    Written(FileConcatOutput),
681    /// One compatible complete input already is the requested output.
682    Reused(usize, FileConcatOutput),
683    /// Compatibility was rejected before the output factory was called.
684    Unsupported(FileConcatReason),
685}
686
687struct PreparedInput<'a> {
688    input: &'a EncodedFileInput,
689    metadata: &'a CachedFileMetadata,
690    schema: &'a Schema,
691}
692
693fn encoded_column_encoding(column: &ColumnInfo) -> Result<Vec<u8>> {
694    Ok(Any::from_msg(&column.encoding)?.encode_to_vec())
695}
696
697fn check_compatibility(
698    target: &FileConcatTarget,
699    inputs: &[PreparedInput<'_>],
700    allow_blob_columns: bool,
701) -> Result<Option<FileConcatReason>> {
702    if !allow_blob_columns
703        && target
704            .schema
705            .fields_pre_order()
706            .any(|field| field.is_blob())
707    {
708        return Ok(Some(FileConcatReason::BlobColumns));
709    }
710
711    let Some(first) = inputs.first() else {
712        return Err(Error::invalid_input(
713            "concat_files requires at least one complete input file",
714        ));
715    };
716    let baseline_columns = &first.metadata.column_infos;
717    let expected_schema = if allow_blob_columns {
718        descriptor_projection_schema(target.schema.as_ref())
719    } else {
720        target.schema.as_ref().clone()
721    };
722    let baseline_encodings = baseline_columns
723        .iter()
724        .map(|column| encoded_column_encoding(column))
725        .collect::<Result<Vec<_>>>()?;
726
727    for (input_index, prepared) in inputs.iter().enumerate() {
728        let metadata = &prepared.metadata;
729        if let Some(expected_num_rows) = prepared.input.expected_num_rows
730            && metadata.num_rows != expected_num_rows
731        {
732            return Err(Error::invalid_input(format!(
733                "input {input_index} at '{}' has {} physical rows but {} were expected",
734                prepared.input.path(),
735                metadata.num_rows,
736                expected_num_rows
737            )));
738        }
739        if metadata.version != target.version {
740            return Ok(Some(FileConcatReason::VersionMismatch {
741                input_index,
742                actual: metadata.version,
743                expected: target.version,
744            }));
745        }
746        if prepared.schema != &expected_schema {
747            return Ok(Some(FileConcatReason::SchemaMismatch { input_index }));
748        }
749        let normalized_rows =
750            versions::validate_external_metadata(metadata.version, prepared.schema, metadata)
751                .map_err(|error| {
752                    Error::corrupt_file(
753                        prepared.input.path().clone(),
754                        format!("input {input_index} has incomplete file metadata: {error}"),
755                    )
756                })?;
757        if normalized_rows != metadata.num_rows {
758            return Err(Error::corrupt_file(
759                prepared.input.path().clone(),
760                format!(
761                    "input {input_index} descriptor reports {} physical rows but its columns normalize to {normalized_rows}",
762                    metadata.num_rows
763                ),
764            ));
765        }
766        if metadata.file_buffers.len() > 1 {
767            return Ok(Some(FileConcatReason::ExtraGlobalBuffers {
768                input_index,
769                count: metadata.file_buffers.len(),
770            }));
771        }
772        if metadata.column_infos.len() != baseline_columns.len() {
773            return Ok(Some(FileConcatReason::ColumnLayoutMismatch {
774                input_index,
775                column_index: None,
776            }));
777        }
778        for (column_index, (column, baseline)) in metadata
779            .column_infos
780            .iter()
781            .zip(baseline_columns)
782            .enumerate()
783        {
784            if !column.buffer_offsets_and_sizes.is_empty() {
785                return Ok(Some(FileConcatReason::ColumnBuffers {
786                    input_index,
787                    column_index,
788                    count: column.buffer_offsets_and_sizes.len(),
789                }));
790            }
791            if column.index != baseline.index {
792                return Ok(Some(FileConcatReason::ColumnLayoutMismatch {
793                    input_index,
794                    column_index: Some(column_index),
795                }));
796            }
797            if encoded_column_encoding(column)? != baseline_encodings[column_index] {
798                return Ok(Some(FileConcatReason::ColumnEncodingMismatch {
799                    input_index,
800                    column_index,
801                }));
802            }
803        }
804    }
805    Ok(None)
806}
807
808async fn copy_page_buffers(
809    writer: &mut crate::writer::FileWriter,
810    scheduler: &FileScheduler,
811    pages: &[PageInfo],
812    read_batch_bytes: u64,
813    input_index: usize,
814    column_index: usize,
815    row_offset: u64,
816) -> Result<Vec<PageInfo>> {
817    let mut copied = Vec::with_capacity(pages.len());
818    let mut page_index = 0;
819    while page_index < pages.len() {
820        let batch_start = page_index;
821        let mut batch_bytes = 0u64;
822        let mut batch_ranges = Vec::new();
823        let mut batch_buffer_counts = Vec::new();
824        while page_index < pages.len() {
825            let page = &pages[page_index];
826            let page_bytes = page.buffer_offsets_and_sizes.iter().try_fold(
827                0u64,
828                |total, (offset, size)| {
829                    offset.checked_add(*size).ok_or_else(|| {
830                        Error::corrupt_file(
831                            scheduler.reader().path().clone(),
832                            format!(
833                                "input {input_index} column {column_index} page {page_index} buffer range overflows"
834                            ),
835                        )
836                    })?;
837                    total.checked_add(*size).ok_or_else(|| {
838                        Error::corrupt_file(
839                            scheduler.reader().path().clone(),
840                            format!(
841                                "input {input_index} column {column_index} page {page_index} buffer sizes overflow"
842                            ),
843                        )
844                    })
845                },
846            )?;
847            if page_index > batch_start
848                && batch_bytes
849                    .checked_add(page_bytes)
850                    .is_none_or(|total| total > read_batch_bytes)
851            {
852                break;
853            }
854            batch_bytes = batch_bytes.checked_add(page_bytes).ok_or_else(|| {
855                Error::corrupt_file(
856                    scheduler.reader().path().clone(),
857                    format!("input {input_index} column {column_index} read batch size overflows"),
858                )
859            })?;
860            batch_buffer_counts.push(page.buffer_offsets_and_sizes.len());
861            batch_ranges.extend(
862                page.buffer_offsets_and_sizes
863                    .iter()
864                    .filter(|(_, size)| *size > 0)
865                    .map(|(offset, size)| *offset..(*offset + *size)),
866            );
867            page_index += 1;
868        }
869
870        let batch_data = if batch_ranges.is_empty() {
871            Vec::new()
872        } else {
873            scheduler.submit_request(batch_ranges, 0).await?
874        };
875        let mut batch_data = batch_data.into_iter();
876        for (relative_page_index, (page, buffer_count)) in pages[batch_start..page_index]
877            .iter()
878            .zip(batch_buffer_counts)
879            .enumerate()
880        {
881            let source_page_index = batch_start + relative_page_index;
882            let mut relocated_buffers = Vec::with_capacity(buffer_count);
883            for (buffer_index, (_, size)) in page.buffer_offsets_and_sizes.iter().enumerate() {
884                let data = if *size == 0 {
885                    None
886                } else {
887                    let data = batch_data.next().ok_or_else(|| {
888                        Error::io(format!(
889                            "short read for input {input_index} column {column_index} page {source_page_index} buffer {buffer_index}: expected {size} bytes"
890                        ))
891                    })?;
892                    if data.len() as u64 != *size {
893                        return Err(Error::io(format!(
894                            "short read for input {input_index} column {column_index} page {source_page_index} buffer {buffer_index}: expected {size} bytes, got {}",
895                            data.len()
896                        )));
897                    }
898                    Some(data)
899                };
900                relocated_buffers.push(
901                    writer
902                        .write_external_buffer(data.as_deref().unwrap_or_default())
903                        .await?,
904                );
905            }
906            copied.push(PageInfo {
907                num_rows: page.num_rows,
908                priority: page.priority.checked_add(row_offset).ok_or_else(|| {
909                    Error::invalid_input_source(
910                        format!(
911                            "input {input_index} column {column_index} page {source_page_index} priority overflows after row relocation"
912                        )
913                        .into(),
914                    )
915                })?,
916                encoding: page.encoding.clone(),
917                buffer_offsets_and_sizes: Arc::from(relocated_buffers),
918            });
919        }
920        if batch_data.next().is_some() {
921            return Err(Error::io(format!(
922                "read for input {input_index} column {column_index} returned more buffers than requested"
923            )));
924        }
925    }
926    Ok(copied)
927}
928
929async fn concat_prepared<Factory, FactoryFuture>(
930    target: &FileConcatTarget,
931    prepared: &[PreparedInput<'_>],
932    allow_blob_columns: bool,
933    reuse_single_input: bool,
934    output_factory: Factory,
935    options: FileConcatOptions,
936) -> Result<FileConcatResult>
937where
938    Factory: FnOnce() -> FactoryFuture,
939    FactoryFuture: Future<Output = Result<Box<dyn ObjectWriter>>>,
940{
941    if options.read_batch_bytes == 0 {
942        return Err(Error::invalid_input(
943            "FileConcatOptions.read_batch_bytes must be greater than zero",
944        ));
945    }
946    if let Some(reason) = check_compatibility(target, prepared, allow_blob_columns)? {
947        return Ok(FileConcatResult::Unsupported(reason));
948    }
949
950    let total_rows = prepared.iter().try_fold(0u64, |total, input| {
951        total.checked_add(input.metadata.num_rows).ok_or_else(|| {
952            Error::invalid_input_source("concat_files total physical row count overflows".into())
953        })
954    })?;
955    if prepared.len() == 1 && reuse_single_input {
956        return Ok(FileConcatResult::Reused(
957            0,
958            FileConcatOutput {
959                version: target.version,
960                num_rows: total_rows,
961                size_bytes: prepared[0].metadata.file_size_bytes,
962            },
963        ));
964    }
965
966    let object_writer = output_factory().await?;
967    let mut writer =
968        versions::create_lazy_writer(target.version, object_writer, options.writer_options)?;
969    let write_result: Result<FileWriteSummary> = async {
970        let column_count = prepared[0].metadata.column_infos.len();
971        let mut output_pages = std::iter::repeat_with(Vec::new)
972            .take(column_count)
973            .collect::<Vec<Vec<PageInfo>>>();
974        let mut row_offset = 0u64;
975
976        for (input_index, prepared_input) in prepared.iter().enumerate() {
977            for (column_index, column) in prepared_input.metadata.column_infos.iter().enumerate() {
978                let has_existing_pages = !output_pages[column_index].is_empty();
979                versions::copy_external_metadata_column(
980                    target.version,
981                    target.schema.as_ref(),
982                    column_index,
983                    has_existing_pages,
984                    || async {
985                        let pages = copy_page_buffers(
986                            &mut writer,
987                            &prepared_input.input.scheduler,
988                            &column.page_infos,
989                            options.read_batch_bytes as u64,
990                            input_index,
991                            column_index,
992                            row_offset,
993                        )
994                        .await?;
995                        output_pages[column_index].extend(pages);
996
997                        Ok(())
998                    },
999                )
1000                .await?;
1001            }
1002            row_offset = row_offset
1003                .checked_add(prepared_input.metadata.num_rows)
1004                .ok_or_else(|| {
1005                    Error::invalid_input_source("concat_files physical row offset overflows".into())
1006                })?;
1007        }
1008
1009        let mut columns = Vec::with_capacity(column_count);
1010        for (column_index, pages) in output_pages.iter_mut().enumerate() {
1011            versions::finalize_external_metadata_column(
1012                target.version,
1013                target.schema.as_ref(),
1014                column_index,
1015                pages,
1016                total_rows,
1017            )?;
1018            let baseline = &prepared[0].metadata.column_infos[column_index];
1019            columns.push(Arc::new(ColumnInfo::new(
1020                baseline.index,
1021                Arc::from(std::mem::take(pages)),
1022                Vec::new(),
1023                baseline.encoding.clone(),
1024            )));
1025        }
1026        // The schema descriptor is the first global buffer and must start at
1027        // the page-buffer alignment required by the reader.
1028        writer.write_external_buffer(&[]).await?;
1029        writer.initialize_with_external_columns(
1030            target.schema.as_ref().clone(),
1031            &columns,
1032            total_rows,
1033        )?;
1034        writer.finish().await
1035    }
1036    .await;
1037
1038    match write_result {
1039        Ok(summary) => Ok(FileConcatResult::Written(FileConcatOutput {
1040            version: target.version,
1041            num_rows: summary.num_rows,
1042            size_bytes: summary.size_bytes,
1043        })),
1044        Err(error) => {
1045            writer.abort().await;
1046            Err(error)
1047        }
1048    }
1049}
1050
1051/// Concatenate complete compatible encoded files in the supplied order.
1052///
1053/// Metadata is read exactly once per input. The factory is invoked only after
1054/// all compatibility checks succeed and is never invoked for [`FileConcatResult::Reused`]
1055/// or [`FileConcatResult::Unsupported`]. Page payloads are copied without Arrow
1056/// decoding; offsets, priorities, exact-version structural metadata, and the
1057/// footer are regenerated.
1058///
1059/// ```
1060/// # use std::sync::Arc;
1061/// # use lance_core::Result;
1062/// # use lance_file::concat::{concat_files, EncodedFileInput, FileConcatOptions, FileConcatResult, FileConcatTarget};
1063/// # use lance_io::object_store::ObjectStore;
1064/// # use object_store::path::Path;
1065/// # async fn stitch(
1066/// #     target: &FileConcatTarget,
1067/// #     inputs: &[EncodedFileInput],
1068/// #     output_store: Arc<ObjectStore>,
1069/// #     output_path: Path,
1070/// # ) -> Result<FileConcatResult> {
1071/// let store = output_store.clone();
1072/// concat_files(
1073///     target,
1074///     inputs,
1075///     move || async move { store.create(&output_path).await },
1076///     FileConcatOptions::default(),
1077/// )
1078/// .await
1079/// # }
1080/// ```
1081pub async fn concat_files<Factory, FactoryFuture>(
1082    target: &FileConcatTarget,
1083    ordered_inputs: &[EncodedFileInput],
1084    output_factory: Factory,
1085    options: FileConcatOptions,
1086) -> Result<FileConcatResult>
1087where
1088    Factory: FnOnce() -> FactoryFuture,
1089    FactoryFuture: Future<Output = Result<Box<dyn ObjectWriter>>>,
1090{
1091    if ordered_inputs.is_empty() {
1092        return Err(Error::invalid_input(
1093            "concat_files requires at least one complete input file",
1094        ));
1095    }
1096    let raw_metadata = futures::future::try_join_all(
1097        ordered_inputs
1098            .iter()
1099            .map(|input| FileReader::read_raw_metadata_for_dispatch(&input.scheduler)),
1100    )
1101    .await?;
1102    if target.version == ConcreteFileVersion::V1
1103        || raw_metadata
1104            .iter()
1105            .any(|metadata| matches!(metadata, RawFileMetadataOpen::Legacy { .. }))
1106    {
1107        return Ok(FileConcatResult::Unsupported(
1108            FileConcatReason::LegacyVersion,
1109        ));
1110    }
1111    let metadata = raw_metadata
1112        .into_iter()
1113        .map(|metadata| match metadata {
1114            RawFileMetadataOpen::Current { version, metadata } => {
1115                versions::finish_metadata(version, metadata)
1116            }
1117            RawFileMetadataOpen::Legacy { .. } => Err(Error::internal(
1118                "legacy concat input reached current metadata finalization".to_string(),
1119            )),
1120        })
1121        .collect::<Result<Vec<_>>>()?;
1122    let prepared = ordered_inputs
1123        .iter()
1124        .zip(metadata.iter())
1125        .map(|(input, metadata)| PreparedInput {
1126            input,
1127            metadata,
1128            schema: metadata.file_schema.as_ref(),
1129        })
1130        .collect::<Vec<_>>();
1131    concat_prepared(target, &prepared, false, true, output_factory, options).await
1132}
1133
1134/// Concatenate validated data-file parts in caller-supplied row order.
1135///
1136/// Unlike [`concat_files`], this entry point accepts Blob v2 columns because
1137/// every [`DataFilePart`] has already rejected file-relative Inline payloads
1138/// and validated managed descriptors against an explicit ID lease. Leases from
1139/// different parts must not overlap. No logical values or Blob payloads are
1140/// decoded and re-encoded during concatenation.
1141pub async fn concat_data_file_parts<Factory, FactoryFuture>(
1142    target: &FileConcatTarget,
1143    ordered_parts: &[DataFilePart],
1144    output_factory: Factory,
1145    options: FileConcatOptions,
1146) -> Result<FileConcatResult>
1147where
1148    Factory: FnOnce() -> FactoryFuture,
1149    FactoryFuture: Future<Output = Result<Box<dyn ObjectWriter>>>,
1150{
1151    if ordered_parts.is_empty() {
1152        return Err(Error::invalid_input(
1153            "concat_data_file_parts requires at least one data-file part",
1154        ));
1155    }
1156    for (part_index, part) in ordered_parts.iter().enumerate() {
1157        if part.blob_target_id != target.blob_target_id {
1158            return Err(Error::invalid_input(format!(
1159                "part {part_index} Blob target ID {:?} does not match target ID {:?}",
1160                part.blob_target_id.as_ref().map(BlobTargetId::as_str),
1161                target.blob_target_id.as_ref().map(BlobTargetId::as_str)
1162            )));
1163        }
1164    }
1165    let mut ranges = ordered_parts
1166        .iter()
1167        .enumerate()
1168        .filter_map(|(part_index, part)| {
1169            part.blob_ids
1170                .clone()
1171                .map(|range| (range.start, range.end, part_index))
1172        })
1173        .collect::<Vec<_>>();
1174    ranges.sort_unstable_by_key(|(start, _, _)| *start);
1175    for pair in ranges.windows(2) {
1176        let (left_start, left_end, left_index) = pair[0];
1177        let (right_start, right_end, right_index) = pair[1];
1178        if right_start < left_end {
1179            return Err(Error::invalid_input(format!(
1180                "part Blob ID ranges overlap: part {left_index} uses {left_start}..{left_end}, part {right_index} uses {right_start}..{right_end}"
1181            )));
1182        }
1183    }
1184
1185    let prepared = ordered_parts
1186        .iter()
1187        .map(|part| PreparedInput {
1188            input: &part.input,
1189            metadata: part.metadata.as_ref(),
1190            schema: part.schema.as_ref(),
1191        })
1192        .collect::<Vec<_>>();
1193    concat_prepared(target, &prepared, true, false, output_factory, options).await
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use std::sync::atomic::{AtomicUsize, Ordering};
1199
1200    use lance_core::utils::tempfile::TempObjFile;
1201    use lance_io::{
1202        object_store::ObjectStore,
1203        scheduler::{ScanScheduler, SchedulerConfig},
1204        traits::Writer,
1205        utils::CachedFileSize,
1206    };
1207    use tokio::io::AsyncWriteExt;
1208
1209    use super::*;
1210
1211    async fn write_file(
1212        store: &Arc<ObjectStore>,
1213        path: &object_store::path::Path,
1214        version: ConcreteFileVersion,
1215        values: &[i32],
1216    ) -> Arc<Schema> {
1217        let batch = arrow_array::record_batch!(("value", Int32, values.to_vec())).unwrap();
1218        let schema = Arc::new(Schema::try_from(batch.schema_ref().as_ref()).unwrap());
1219        let mut writer = versions::create_writer(
1220            version,
1221            store.create(path).await.unwrap(),
1222            schema.as_ref().clone(),
1223            FileWriterOptions::default(),
1224        )
1225        .unwrap();
1226        writer.write_batch(&batch).await.unwrap();
1227        writer.finish().await.unwrap();
1228        schema
1229    }
1230
1231    async fn input(
1232        store: Arc<ObjectStore>,
1233        path: &object_store::path::Path,
1234        expected_num_rows: u64,
1235    ) -> EncodedFileInput {
1236        let scheduler = ScanScheduler::new(store, SchedulerConfig::default_for_testing());
1237        let file = scheduler
1238            .open_file(path, &CachedFileSize::unknown())
1239            .await
1240            .unwrap();
1241        EncodedFileInput::new(file).with_expected_num_rows(expected_num_rows)
1242    }
1243
1244    #[tokio::test]
1245    async fn concat_writes_relocated_metadata_and_reuses_single_input() {
1246        let store = Arc::new(ObjectStore::local());
1247        let first_path = TempObjFile::default();
1248        let second_path = TempObjFile::default();
1249        let output_path = TempObjFile::default();
1250        let schema = write_file(&store, &first_path, ConcreteFileVersion::V2_1, &[1, 2, 3]).await;
1251        write_file(&store, &second_path, ConcreteFileVersion::V2_1, &[4, 5]).await;
1252        let inputs = vec![
1253            input(store.clone(), &first_path, 3).await,
1254            input(store.clone(), &second_path, 2).await,
1255        ];
1256        let target = FileConcatTarget::new(ConcreteFileVersion::V2_1, schema);
1257        let factory_calls = Arc::new(AtomicUsize::new(0));
1258        let result = concat_files(
1259            &target,
1260            &inputs,
1261            {
1262                let store = store.clone();
1263                let output_path = output_path.clone();
1264                let factory_calls = factory_calls.clone();
1265                move || async move {
1266                    factory_calls.fetch_add(1, Ordering::SeqCst);
1267                    store.create(&output_path).await
1268                }
1269            },
1270            FileConcatOptions::default(),
1271        )
1272        .await
1273        .unwrap();
1274        assert!(matches!(
1275            result,
1276            FileConcatResult::Written(FileConcatOutput { num_rows: 5, .. })
1277        ));
1278        assert_eq!(factory_calls.load(Ordering::SeqCst), 1);
1279        let output = input(store.clone(), &output_path, 5).await;
1280        let metadata = FileReader::read_all_metadata(&output.scheduler)
1281            .await
1282            .unwrap();
1283        assert_eq!(metadata.num_rows, 5);
1284        assert_eq!(metadata.column_infos[0].page_infos.len(), 2);
1285        assert!(
1286            metadata.column_infos[0].page_infos[0].priority
1287                < metadata.column_infos[0].page_infos[1].priority
1288        );
1289
1290        let reuse_calls = Arc::new(AtomicUsize::new(0));
1291        let result = concat_files(
1292            &target,
1293            &inputs[..1],
1294            {
1295                let reuse_calls = reuse_calls.clone();
1296                move || async move {
1297                    reuse_calls.fetch_add(1, Ordering::SeqCst);
1298                    Err(Error::internal("reuse factory must not be called"))
1299                }
1300            },
1301            FileConcatOptions::default(),
1302        )
1303        .await
1304        .unwrap();
1305        assert!(matches!(result, FileConcatResult::Reused(0, _)));
1306        assert_eq!(reuse_calls.load(Ordering::SeqCst), 0);
1307    }
1308
1309    #[rstest::rstest]
1310    #[case(ConcreteFileVersion::V2_0)]
1311    #[case(ConcreteFileVersion::V2_1)]
1312    #[case(ConcreteFileVersion::V2_2)]
1313    #[case(ConcreteFileVersion::V2_3)]
1314    #[tokio::test]
1315    async fn concat_preserves_schema_metadata(#[case] version: ConcreteFileVersion) {
1316        let store = Arc::new(ObjectStore::local());
1317        let first_path = TempObjFile::default();
1318        let second_path = TempObjFile::default();
1319        let output_path = TempObjFile::default();
1320        let batch = arrow_array::record_batch!(("value", Int32, [1, 2])).unwrap();
1321        let mut schema = Schema::try_from(batch.schema_ref().as_ref()).unwrap();
1322        schema
1323            .metadata
1324            .insert("review-key".into(), "review-value".into());
1325        let schema = Arc::new(schema);
1326
1327        for path in [&first_path, &second_path] {
1328            let mut writer = versions::create_writer(
1329                version,
1330                store.create(path).await.unwrap(),
1331                schema.as_ref().clone(),
1332                FileWriterOptions::default(),
1333            )
1334            .unwrap();
1335            writer.write_batch(&batch).await.unwrap();
1336            writer.finish().await.unwrap();
1337        }
1338        let inputs = vec![
1339            input(store.clone(), &first_path, 2).await,
1340            input(store.clone(), &second_path, 2).await,
1341        ];
1342        let result = concat_files(
1343            &FileConcatTarget::new(version, schema),
1344            &inputs,
1345            {
1346                let store = store.clone();
1347                let output_path = output_path.clone();
1348                move || async move { store.create(&output_path).await }
1349            },
1350            FileConcatOptions::default(),
1351        )
1352        .await
1353        .unwrap();
1354        assert!(matches!(result, FileConcatResult::Written(_)));
1355
1356        let output = input(store, &output_path, 4).await;
1357        let metadata = FileReader::read_all_metadata(&output.scheduler)
1358            .await
1359            .unwrap();
1360        assert_eq!(
1361            metadata.file_schema.metadata.get("review-key"),
1362            Some(&"review-value".to_string())
1363        );
1364    }
1365
1366    #[tokio::test]
1367    async fn unsupported_does_not_create_output() {
1368        let store = Arc::new(ObjectStore::local());
1369        let first_path = TempObjFile::default();
1370        let second_path = TempObjFile::default();
1371        let schema = write_file(&store, &first_path, ConcreteFileVersion::V2_1, &[1]).await;
1372        write_file(&store, &second_path, ConcreteFileVersion::V2_2, &[2]).await;
1373        let inputs = vec![
1374            input(store.clone(), &first_path, 1).await,
1375            input(store, &second_path, 1).await,
1376        ];
1377        let factory_calls = Arc::new(AtomicUsize::new(0));
1378        let result = concat_files(
1379            &FileConcatTarget::new(ConcreteFileVersion::V2_1, schema),
1380            &inputs,
1381            {
1382                let factory_calls = factory_calls.clone();
1383                move || async move {
1384                    factory_calls.fetch_add(1, Ordering::SeqCst);
1385                    Err(Error::internal("unsupported factory must not be called"))
1386                }
1387            },
1388            FileConcatOptions::default(),
1389        )
1390        .await
1391        .unwrap();
1392        assert!(matches!(
1393            result,
1394            FileConcatResult::Unsupported(FileConcatReason::VersionMismatch { input_index: 1, .. })
1395        ));
1396        assert_eq!(factory_calls.load(Ordering::SeqCst), 0);
1397    }
1398
1399    #[tokio::test]
1400    async fn legacy_input_is_unsupported_without_creating_output() {
1401        let store = Arc::new(ObjectStore::local());
1402        let current_path = TempObjFile::default();
1403        let legacy_path = TempObjFile::default();
1404        let schema = write_file(&store, &current_path, ConcreteFileVersion::V2_1, &[1]).await;
1405        let mut legacy_writer = store.create(&legacy_path).await.unwrap();
1406        legacy_writer
1407            .write_all(include_bytes!("../test_data/exact_versions/v1.lance"))
1408            .await
1409            .unwrap();
1410        Writer::shutdown(&mut legacy_writer).await.unwrap();
1411        let factory_calls = Arc::new(AtomicUsize::new(0));
1412
1413        let result = concat_files(
1414            &FileConcatTarget::new(ConcreteFileVersion::V2_1, schema),
1415            &[input(store, &legacy_path, 0).await],
1416            {
1417                let factory_calls = factory_calls.clone();
1418                move || async move {
1419                    factory_calls.fetch_add(1, Ordering::SeqCst);
1420                    Err(Error::internal("legacy factory must not be called"))
1421                }
1422            },
1423            FileConcatOptions::default(),
1424        )
1425        .await
1426        .unwrap();
1427
1428        assert!(matches!(
1429            result,
1430            FileConcatResult::Unsupported(FileConcatReason::LegacyVersion)
1431        ));
1432        assert_eq!(factory_calls.load(Ordering::SeqCst), 0);
1433    }
1434
1435    #[tokio::test]
1436    async fn incompatible_column_buffers_and_incomplete_metadata_are_rejected() {
1437        let store = Arc::new(ObjectStore::local());
1438        let path = TempObjFile::default();
1439        let schema = write_file(&store, &path, ConcreteFileVersion::V2_1, &[1, 2]).await;
1440        let encoded_input = input(store, &path, 2).await;
1441        let target = FileConcatTarget::new(ConcreteFileVersion::V2_1, schema);
1442
1443        let mut with_column_buffer = FileReader::read_all_metadata(&encoded_input.scheduler)
1444            .await
1445            .unwrap();
1446        let column = with_column_buffer.column_infos[0].as_ref();
1447        with_column_buffer.column_infos[0] = Arc::new(ColumnInfo::new(
1448            column.index,
1449            column.page_infos.clone(),
1450            vec![(0, 1)],
1451            column.encoding.clone(),
1452        ));
1453        let prepared = [PreparedInput {
1454            input: &encoded_input,
1455            metadata: &with_column_buffer,
1456            schema: with_column_buffer.file_schema.as_ref(),
1457        }];
1458        assert!(matches!(
1459            check_compatibility(&target, &prepared, false).unwrap(),
1460            Some(FileConcatReason::ColumnBuffers {
1461                input_index: 0,
1462                column_index: 0,
1463                count: 1
1464            })
1465        ));
1466
1467        let mut missing_column = FileReader::read_all_metadata(&encoded_input.scheduler)
1468            .await
1469            .unwrap();
1470        missing_column.column_infos.clear();
1471        let prepared = [PreparedInput {
1472            input: &encoded_input,
1473            metadata: &missing_column,
1474            schema: missing_column.file_schema.as_ref(),
1475        }];
1476        let error = check_compatibility(&target, &prepared, false).unwrap_err();
1477        assert!(matches!(error, Error::CorruptFile { .. }));
1478        assert!(
1479            error
1480                .to_string()
1481                .contains("schema requires 1 physical columns")
1482        );
1483
1484        let mut wrong_rows = FileReader::read_all_metadata(&encoded_input.scheduler)
1485            .await
1486            .unwrap();
1487        let column = wrong_rows.column_infos[0].as_ref();
1488        let mut pages = column
1489            .page_infos
1490            .iter()
1491            .map(|page| PageInfo {
1492                num_rows: page.num_rows,
1493                priority: page.priority,
1494                encoding: page.encoding.clone(),
1495                buffer_offsets_and_sizes: page.buffer_offsets_and_sizes.clone(),
1496            })
1497            .collect::<Vec<_>>();
1498        pages[0].num_rows -= 1;
1499        wrong_rows.column_infos[0] = Arc::new(ColumnInfo::new(
1500            column.index,
1501            Arc::from(pages),
1502            Vec::new(),
1503            column.encoding.clone(),
1504        ));
1505        let prepared = [PreparedInput {
1506            input: &encoded_input,
1507            metadata: &wrong_rows,
1508            schema: wrong_rows.file_schema.as_ref(),
1509        }];
1510        let error = check_compatibility(&target, &prepared, false).unwrap_err();
1511        assert!(matches!(error, Error::CorruptFile { .. }));
1512        assert!(
1513            error
1514                .to_string()
1515                .contains("descriptor reports 2 physical rows")
1516        );
1517    }
1518
1519    #[tokio::test]
1520    async fn data_file_parts_reject_overlapping_blob_leases_before_output() {
1521        let store = Arc::new(ObjectStore::local());
1522        let first_path = TempObjFile::default();
1523        let second_path = TempObjFile::default();
1524        let schema = write_file(&store, &first_path, ConcreteFileVersion::V2_1, &[1]).await;
1525        write_file(&store, &second_path, ConcreteFileVersion::V2_1, &[2]).await;
1526        let first = DataFilePart::open(
1527            input(store.clone(), &first_path, 1).await,
1528            Some(1..10),
1529            None,
1530        )
1531        .await
1532        .unwrap();
1533        let second = DataFilePart::open(input(store, &second_path, 1).await, Some(5..20), None)
1534            .await
1535            .unwrap();
1536        let factory_calls = Arc::new(AtomicUsize::new(0));
1537
1538        let error = concat_data_file_parts(
1539            &FileConcatTarget::new(ConcreteFileVersion::V2_1, schema),
1540            &[first, second],
1541            {
1542                let factory_calls = factory_calls.clone();
1543                move || async move {
1544                    factory_calls.fetch_add(1, Ordering::SeqCst);
1545                    Err(Error::internal("overlap factory must not be called"))
1546                }
1547            },
1548            FileConcatOptions::default(),
1549        )
1550        .await
1551        .unwrap_err();
1552
1553        assert!(error.to_string().contains("part 0 uses 1..10"), "{error}");
1554        assert!(error.to_string().contains("part 1 uses 5..20"), "{error}");
1555        assert_eq!(factory_calls.load(Ordering::SeqCst), 0);
1556    }
1557
1558    #[tokio::test]
1559    async fn missing_and_corrupt_inputs_are_errors_without_output() {
1560        let store = Arc::new(ObjectStore::local());
1561        let valid_path = TempObjFile::default();
1562        let missing_path = TempObjFile::default();
1563        let corrupt_path = TempObjFile::default();
1564        let schema = write_file(&store, &valid_path, ConcreteFileVersion::V2_1, &[1, 2]).await;
1565        write_file(&store, &missing_path, ConcreteFileVersion::V2_1, &[3, 4]).await;
1566        let missing_input = input(store.clone(), &missing_path, 2).await;
1567        store.delete(&missing_path).await.unwrap();
1568
1569        let target = FileConcatTarget::new(ConcreteFileVersion::V2_1, schema.clone());
1570        let factory_calls = Arc::new(AtomicUsize::new(0));
1571        let result = concat_files(
1572            &target,
1573            &[input(store.clone(), &valid_path, 2).await, missing_input],
1574            {
1575                let factory_calls = factory_calls.clone();
1576                move || async move {
1577                    factory_calls.fetch_add(1, Ordering::SeqCst);
1578                    Err(Error::internal("error factory must not be called"))
1579                }
1580            },
1581            FileConcatOptions::default(),
1582        )
1583        .await;
1584        assert!(result.is_err());
1585        assert_eq!(factory_calls.load(Ordering::SeqCst), 0);
1586
1587        let mut corrupt_writer = store.create(&corrupt_path).await.unwrap();
1588        corrupt_writer.write_all(b"not a Lance file").await.unwrap();
1589        Writer::shutdown(&mut corrupt_writer).await.unwrap();
1590        let corrupt_input = input(store.clone(), &corrupt_path, 2).await;
1591        let result = concat_files(
1592            &target,
1593            &[input(store, &valid_path, 2).await, corrupt_input],
1594            || async { Err(Error::internal("error factory must not be called")) },
1595            FileConcatOptions::default(),
1596        )
1597        .await;
1598        assert!(result.is_err());
1599    }
1600}