Skip to content

Commit 31a1e50

Browse files
authored
perf: support indexed metadata for structural projections (#7790)
1 parent cf3d850 commit 31a1e50

2 files changed

Lines changed: 322 additions & 13 deletions

File tree

rust/lance-file/src/reader.rs

Lines changed: 274 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,30 @@ fn field_column_shape(field: &Field, is_structural: bool) -> (bool, bool) {
536536
(contributes, recurse)
537537
}
538538

539+
// Count the V2.1 physical columns required to reconstruct a projected field.
540+
// This is the same DFS shape consumed by `ColumnInfoIter`: ordinary structural
541+
// nodes are transparent and leaves contribute columns. Indexed metadata loading
542+
// can therefore compact any ordinary structural projection into 0..N while
543+
// preserving this order.
544+
//
545+
// Blob and packed-struct fields remain unsupported by indexed projection. Their
546+
// opaque decode semantics are handled by the existing full-metadata reader.
547+
fn indexed_projection_column_count(field: &Field) -> Option<usize> {
548+
if field.is_blob() || field.is_packed_struct() {
549+
return None;
550+
}
551+
552+
let (contributes, recurse) = field_column_shape(field, true);
553+
let initial = usize::from(contributes);
554+
if !recurse {
555+
return Some(initial);
556+
}
557+
558+
field.children.iter().try_fold(initial, |count, child| {
559+
count.checked_add(indexed_projection_column_count(child)?)
560+
})
561+
}
562+
539563
// Whether a field's children each cover the same rows as the field itself. Struct
540564
// children do (one value per parent row), so they must share its length. List,
541565
// map, and fixed-size-list items have an independent cardinality (item count, not
@@ -1836,12 +1860,18 @@ impl FileMetadataProvider {
18361860
projection: &ReaderProjection,
18371861
version: LanceFileVersion,
18381862
) -> bool {
1839-
version >= LanceFileVersion::V2_1
1840-
&& !projection.schema.fields.is_empty()
1841-
&& projection.schema.fields.len() == projection.column_indices.len()
1842-
&& projection.schema.fields.iter().all(|field| {
1843-
field.children.is_empty() && !field.is_blob() && !field.is_packed_struct()
1863+
if version < LanceFileVersion::V2_1 || projection.schema.fields.is_empty() {
1864+
return false;
1865+
}
1866+
1867+
projection
1868+
.schema
1869+
.fields
1870+
.iter()
1871+
.try_fold(0usize, |count, field| {
1872+
count.checked_add(indexed_projection_column_count(field)?)
18441873
})
1874+
== Some(projection.column_indices.len())
18451875
}
18461876

18471877
fn validate_indexed_projection(
@@ -1871,7 +1901,7 @@ impl FileMetadataProvider {
18711901
}
18721902
if !Self::supports_indexed_projection(projection, metadata_index.version) {
18731903
return Err(Error::not_supported(format!(
1874-
"lazy column metadata loading only supports direct V2.1+ top-level physical column projections; got file version {:?}, {} schema fields, and {} column indices",
1904+
"lazy column metadata loading requires a V2.1+ ordinary structural projection without blob or packed-struct fields whose physical-column count matches the projection; got file version {:?}, {} schema fields, and {} column indices",
18751905
metadata_index.version,
18761906
projection.schema.fields.len(),
18771907
projection.column_indices.len()
@@ -2593,7 +2623,7 @@ mod tests {
25932623
use futures::{StreamExt, prelude::stream::TryStreamExt};
25942624
use lance_arrow::{BLOB_META_KEY, RecordBatchExt};
25952625
use lance_core::{ArrowResult, datatypes::Schema};
2596-
use lance_datagen::{BatchCount, ByteCount, RowCount, array, gen_batch};
2626+
use lance_datagen::{ArrayGeneratorExt, BatchCount, ByteCount, RowCount, array, gen_batch};
25972627
use lance_encoding::{
25982628
decoder::{
25992629
DecodeBatchScheduler, DecoderPlugins, FilterExpression, ReadBatchTask, decode_batch,
@@ -2660,6 +2690,60 @@ mod tests {
26602690
.await
26612691
}
26622692

2693+
async fn create_wide_fixed_size_list_file(fs: &FsFixture, num_columns: usize) -> WrittenFile {
2694+
let data_type =
2695+
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4);
2696+
let mut reader = gen_batch();
2697+
for column_idx in 0..num_columns {
2698+
reader = reader.col(
2699+
format!("c{column_idx}"),
2700+
array::rand_type(&data_type).with_random_nulls(0.1),
2701+
);
2702+
}
2703+
let reader = reader.into_reader_rows(RowCount::from(64), BatchCount::from(4));
2704+
2705+
write_lance_file(
2706+
reader,
2707+
fs,
2708+
FileWriterOptions {
2709+
format_version: Some(LanceFileVersion::V2_1),
2710+
..Default::default()
2711+
},
2712+
)
2713+
.await
2714+
}
2715+
2716+
async fn create_wide_structural_file(fs: &FsFixture, num_groups: usize) -> WrittenFile {
2717+
let struct_type = DataType::Struct(Fields::from(vec![
2718+
Field::new("x", DataType::Int32, true),
2719+
Field::new("y", DataType::Int32, true),
2720+
]));
2721+
let list_type = DataType::List(Arc::new(Field::new("item", DataType::Int32, true)));
2722+
let mut reader = gen_batch();
2723+
for group_idx in 0..num_groups {
2724+
reader = reader
2725+
.col(
2726+
format!("s{group_idx}"),
2727+
array::rand_type(&struct_type).with_random_nulls(0.5),
2728+
)
2729+
.col(
2730+
format!("l{group_idx}"),
2731+
array::rand_type(&list_type).with_random_nulls(0.5),
2732+
);
2733+
}
2734+
let reader = reader.into_reader_rows(RowCount::from(64), BatchCount::from(4));
2735+
2736+
write_lance_file(
2737+
reader,
2738+
fs,
2739+
FileWriterOptions {
2740+
format_version: Some(LanceFileVersion::V2_1),
2741+
..Default::default()
2742+
},
2743+
)
2744+
.await
2745+
}
2746+
26632747
type Transformer = Box<dyn Fn(&RecordBatch) -> RecordBatch>;
26642748

26652749
async fn verify_expected(
@@ -3157,6 +3241,170 @@ mod tests {
31573241
);
31583242
}
31593243

3244+
async fn assert_lazy_projection_matches_eager_and_reads_metadata_subset(
3245+
fs: &FsFixture,
3246+
projection: ReaderProjection,
3247+
shape: &str,
3248+
) -> Vec<RecordBatch> {
3249+
let file_scheduler = fs
3250+
.scheduler
3251+
.open_file(&fs.tmp_path, &CachedFileSize::unknown())
3252+
.await
3253+
.unwrap();
3254+
let eager_reader = FileReader::try_open(
3255+
file_scheduler.clone(),
3256+
None,
3257+
Arc::<DecoderPlugins>::default(),
3258+
&test_cache(),
3259+
FileReaderOptions::default(),
3260+
)
3261+
.await
3262+
.unwrap();
3263+
let expected = eager_reader
3264+
.read_stream_projected(
3265+
lance_io::ReadBatchParams::RangeFull,
3266+
127,
3267+
16,
3268+
projection.clone(),
3269+
FilterExpression::no_filter(),
3270+
)
3271+
.await
3272+
.unwrap()
3273+
.try_collect::<Vec<_>>()
3274+
.await
3275+
.unwrap();
3276+
3277+
let cache = test_cache();
3278+
let lazy_reader = ProjectedFileReader::try_open(
3279+
file_scheduler,
3280+
Some(projection.clone()),
3281+
Arc::<DecoderPlugins>::default(),
3282+
&cache,
3283+
FileReaderOptions::default(),
3284+
)
3285+
.await
3286+
.unwrap();
3287+
let metadata_index = lazy_reader.metadata_index().unwrap();
3288+
let requested_metadata_bytes = projection
3289+
.column_indices
3290+
.iter()
3291+
.map(|column_index| metadata_index.column_metadata_offsets[*column_index as usize].1)
3292+
.sum::<u64>();
3293+
let total_metadata_bytes = metadata_index
3294+
.column_metadata_offsets
3295+
.iter()
3296+
.map(|(_, length)| *length)
3297+
.sum::<u64>();
3298+
assert!(total_metadata_bytes > requested_metadata_bytes * 8);
3299+
3300+
fs.object_store.io_stats_incremental();
3301+
let tasks = lazy_reader
3302+
.read_tasks(
3303+
lance_io::ReadBatchParams::Range(0..0),
3304+
127,
3305+
None,
3306+
FilterExpression::no_filter(),
3307+
)
3308+
.await
3309+
.unwrap();
3310+
assert!(collect_read_tasks(tasks, 1).await.is_empty());
3311+
let metadata_stats = fs.object_store.io_stats_incremental();
3312+
assert!(
3313+
metadata_stats.read_bytes < total_metadata_bytes / 2,
3314+
"lazy {shape} read fetched too much metadata: read {} bytes, requested column metadata is {} bytes, total column metadata is {} bytes",
3315+
metadata_stats.read_bytes,
3316+
requested_metadata_bytes,
3317+
total_metadata_bytes
3318+
);
3319+
3320+
let tasks = lazy_reader
3321+
.read_tasks(
3322+
lance_io::ReadBatchParams::RangeFull,
3323+
127,
3324+
None,
3325+
FilterExpression::no_filter(),
3326+
)
3327+
.await
3328+
.unwrap();
3329+
let actual = collect_read_tasks(tasks, 16).await;
3330+
assert_eq!(expected, actual);
3331+
actual
3332+
}
3333+
3334+
#[tokio::test]
3335+
async fn test_lazy_reader_fixed_size_list_projection_matches_eager_reader() {
3336+
let fs = FsFixture::default();
3337+
let written_file = create_wide_fixed_size_list_file(&fs, 512).await;
3338+
let projection = ReaderProjection::from_column_names(
3339+
LanceFileVersion::V2_1,
3340+
&written_file.schema,
3341+
&["c17", "c509"],
3342+
)
3343+
.unwrap();
3344+
assert!(ProjectedFileReader::supports_projection(
3345+
&projection,
3346+
LanceFileVersion::V2_1
3347+
));
3348+
assert!(!ProjectedFileReader::supports_projection(
3349+
&projection,
3350+
LanceFileVersion::V2_0
3351+
));
3352+
assert_lazy_projection_matches_eager_and_reads_metadata_subset(
3353+
&fs,
3354+
projection,
3355+
"fixed-size-list",
3356+
)
3357+
.await;
3358+
}
3359+
3360+
#[tokio::test]
3361+
async fn test_lazy_reader_nested_projection_compacts_physical_columns() {
3362+
let fs = FsFixture::default();
3363+
let written_file = create_wide_structural_file(&fs, 128).await;
3364+
let projection = ReaderProjection::from_column_names(
3365+
LanceFileVersion::V2_1,
3366+
&written_file.schema,
3367+
&["s97.y", "l4", "s3"],
3368+
)
3369+
.unwrap();
3370+
3371+
assert_eq!(
3372+
projection
3373+
.schema
3374+
.fields
3375+
.iter()
3376+
.map(|field| field.name.as_str())
3377+
.collect::<Vec<_>>(),
3378+
vec!["s97", "l4", "s3"]
3379+
);
3380+
assert_eq!(projection.schema.fields[0].children.len(), 1);
3381+
assert_eq!(projection.schema.fields[0].children[0].name, "y");
3382+
assert_eq!(projection.schema.fields[2].children.len(), 2);
3383+
assert_eq!(projection.column_indices.len(), 4);
3384+
assert!(
3385+
projection
3386+
.column_indices
3387+
.windows(2)
3388+
.any(|indices| indices[0] > indices[1]),
3389+
"the projection must reorder physical columns to exercise compact remapping"
3390+
);
3391+
assert!(ProjectedFileReader::supports_projection(
3392+
&projection,
3393+
LanceFileVersion::V2_1
3394+
));
3395+
let actual = assert_lazy_projection_matches_eager_and_reads_metadata_subset(
3396+
&fs, projection, "nested",
3397+
)
3398+
.await;
3399+
assert!(
3400+
actual
3401+
.iter()
3402+
.flat_map(|batch| batch.columns())
3403+
.any(|column| column.null_count() > 0),
3404+
"the structural projection must exercise nullable arrays"
3405+
);
3406+
}
3407+
31603408
#[rstest]
31613409
#[case::before_metadata_region(90, 5)]
31623410
#[case::after_metadata_region(190, 20)]
@@ -3185,19 +3433,23 @@ mod tests {
31853433
);
31863434
}
31873435

3436+
#[rstest]
3437+
#[case::blob(BLOB_META_KEY)]
3438+
#[case::packed_struct("lance-encoding:packed")]
31883439
#[tokio::test]
3189-
async fn test_lazy_reader_rejects_unsupported_projection() {
3440+
async fn test_lazy_reader_rejects_opaque_projection(#[case] metadata_key: &str) {
31903441
let fs = FsFixture::default();
31913442
let written_file = create_some_file(&fs, LanceFileVersion::V2_1).await;
31923443

3193-
let projection = ReaderProjection::from_column_names(
3444+
let ordinary_projection = ReaderProjection::from_column_names(
31943445
LanceFileVersion::V2_1,
31953446
&written_file.schema,
3196-
&["location"],
3447+
&["location.x"],
31973448
)
31983449
.unwrap();
3199-
assert!(!ProjectedFileReader::supports_projection(
3200-
&projection,
3450+
assert_eq!(ordinary_projection.schema.fields[0].children.len(), 1);
3451+
assert!(ProjectedFileReader::supports_projection(
3452+
&ordinary_projection,
32013453
LanceFileVersion::V2_1
32023454
));
32033455

@@ -3220,6 +3472,15 @@ mod tests {
32203472
"expected InvalidInput, got {err:?}"
32213473
);
32223474

3475+
let mut projection = ordinary_projection;
3476+
Arc::make_mut(&mut projection.schema).fields[0]
3477+
.metadata
3478+
.insert(metadata_key.to_string(), "true".to_string());
3479+
assert!(!ProjectedFileReader::supports_projection(
3480+
&projection,
3481+
LanceFileVersion::V2_1
3482+
));
3483+
32233484
let err = ProjectedFileReader::try_open(
32243485
file_scheduler,
32253486
Some(projection),
@@ -3231,7 +3492,7 @@ mod tests {
32313492
.unwrap_err();
32323493
assert!(
32333494
matches!(err, lance_core::Error::NotSupported { .. }),
3234-
"expected NotSupported, got {err:?}"
3495+
"expected NotSupported for {metadata_key}, got {err:?}"
32353496
);
32363497
}
32373498

0 commit comments

Comments
 (0)