1use 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#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct BlobTargetId(Arc<str>);
47
48impl BlobTargetId {
49 pub fn new(identity: impl Into<Arc<str>>) -> Self {
51 Self(identity.into())
52 }
53
54 pub fn as_str(&self) -> &str {
56 self.0.as_ref()
57 }
58}
59
60#[derive(Clone)]
62pub struct EncodedFileInput {
63 scheduler: FileScheduler,
64 expected_num_rows: Option<u64>,
65}
66
67impl EncodedFileInput {
68 pub fn new(scheduler: FileScheduler) -> Self {
70 Self {
71 scheduler,
72 expected_num_rows: None,
73 }
74 }
75
76 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 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#[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 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 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
266fn 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#[derive(Debug, Clone)]
515pub struct FileConcatTarget {
516 pub version: ConcreteFileVersion,
518 pub schema: Arc<Schema>,
520 blob_target_id: Option<BlobTargetId>,
521}
522
523impl FileConcatTarget {
524 pub fn new(version: ConcreteFileVersion, schema: Arc<Schema>) -> Self {
526 Self {
527 version,
528 schema,
529 blob_target_id: None,
530 }
531 }
532
533 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#[derive(Debug, Clone)]
542pub struct FileConcatOptions {
543 pub read_batch_bytes: usize,
545 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub struct FileConcatOutput {
561 pub version: ConcreteFileVersion,
563 pub num_rows: u64,
565 pub size_bytes: u64,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq)]
571pub enum FileConcatReason {
572 LegacyVersion,
574 VersionMismatch {
576 input_index: usize,
578 actual: ConcreteFileVersion,
580 expected: ConcreteFileVersion,
582 },
583 SchemaMismatch {
585 input_index: usize,
587 },
588 ColumnLayoutMismatch {
590 input_index: usize,
592 column_index: Option<usize>,
594 },
595 ColumnEncodingMismatch {
597 input_index: usize,
599 column_index: usize,
601 },
602 ColumnBuffers {
604 input_index: usize,
606 column_index: usize,
608 count: usize,
610 },
611 ExtraGlobalBuffers {
613 input_index: usize,
615 count: usize,
617 },
618 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#[derive(Debug, Clone, PartialEq, Eq)]
678pub enum FileConcatResult {
679 Written(FileConcatOutput),
681 Reused(usize, FileConcatOutput),
683 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 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
1051pub 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
1134pub 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, ¤t_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}