Skip to content

Commit 66a2bf7

Browse files
Make opening a Vortex file with a cached footer cheaper (#10358)
Every DataFusion partition reopens each file it scans, even when the footer is cached. Some of the work done on each of those opens grows with the file's segment count, or with the square of its column count. On index-driven scans the open dominates the query, so this overhead showed up directly in query latency.
1 parent adf3bfa commit 66a2bf7

4 files changed

Lines changed: 102 additions & 13 deletions

File tree

‎vortex-datafusion/src/convert/schema.rs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,20 @@ pub fn calculate_physical_schema(
3333
));
3434
};
3535

36+
let reference_fields = reference_logical_schema.fields();
3637
let fields: Vec<Field> = struct_dtype
3738
.names()
3839
.iter()
3940
.zip(struct_dtype.fields())
40-
.map(|(name, field_dtype)| {
41-
let logical_field = reference_logical_schema.field_with_name(name.as_ref()).ok();
41+
.enumerate()
42+
.map(|(idx, (name, field_dtype))| {
43+
// Files usually share the table's column order, so try the field at the same position
44+
// before searching by name, which would make wide schemas quadratic.
45+
let logical_field = reference_fields
46+
.get(idx)
47+
.filter(|field| field.name() == name.as_ref())
48+
.map(|field| field.as_ref())
49+
.or_else(|| reference_logical_schema.field_with_name(name.as_ref()).ok());
4250
match logical_field {
4351
Some(logical_field) => {
4452
let arrow_type = calculate_physical_field_type(

‎vortex-datafusion/src/persistent/opener.rs‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::sync::Weak;
88
use arrow_array::RecordBatchOptions;
99
use arrow_schema::Field;
1010
use arrow_schema::Schema;
11+
use arrow_schema::SchemaRef;
1112
use datafusion_common::DataFusionError;
1213
use datafusion_common::Result as DFResult;
1314
use datafusion_common::ScalarValue;
@@ -97,6 +98,8 @@ pub(crate) struct VortexOpener {
9798
pub layout_readers: Arc<DashMap<Path, Weak<dyn LayoutReader>>>,
9899
/// Shared full-file natural splits keyed by file path.
99100
pub natural_splits: Arc<DashMap<Path, Arc<NaturalSplits>>>,
101+
/// Shared physical Arrow schemas of each file, keyed by file path.
102+
pub file_schemas: Arc<DashMap<Path, SchemaRef>>,
100103
/// Whether the query has output ordering specified
101104
pub has_output_ordering: bool,
102105

@@ -138,6 +141,7 @@ impl FileOpener for VortexOpener {
138141
let limit = self.limit;
139142
let layout_readers = Arc::clone(&self.layout_readers);
140143
let natural_splits = Arc::clone(&self.natural_splits);
144+
let file_schemas = Arc::clone(&self.file_schemas);
141145
let has_output_ordering = self.has_output_ordering;
142146
let scan_concurrency = self.scan_concurrency;
143147

@@ -238,12 +242,20 @@ impl FileOpener for VortexOpener {
238242
}
239243

240244
// This is the expected arrow types of the actual columns in the file, which might have different types
241-
// from the unified logical schema or miss
242-
let this_file_schema = Arc::new(calculate_physical_schema(
243-
vxf.dtype(),
244-
&unified_file_schema,
245-
&session.arrow(),
246-
)?);
245+
// from the unified logical schema or miss. Every partition opening this file computes
246+
// the same schema, so it is shared across them.
247+
let this_file_schema = match file_schemas.get(&file.object_meta.location) {
248+
Some(schema) => Arc::clone(schema.value()),
249+
None => {
250+
let schema = Arc::new(calculate_physical_schema(
251+
vxf.dtype(),
252+
&unified_file_schema,
253+
&session.arrow(),
254+
)?);
255+
file_schemas.insert(file.object_meta.location.clone(), Arc::clone(&schema));
256+
schema
257+
}
258+
};
247259

248260
let expr_adapter = expr_adapter_factory.create(
249261
Arc::clone(&unified_file_schema),
@@ -865,6 +877,7 @@ mod tests {
865877
df_metrics: ExecutionPlanMetricsSet::new(),
866878
layout_readers: Default::default(),
867879
natural_splits: Default::default(),
880+
file_schemas: Default::default(),
868881
has_output_ordering: false,
869882
expression_convertor: Arc::new(DefaultExpressionConvertor::default()),
870883
file_metadata_cache: None,
@@ -1148,6 +1161,7 @@ mod tests {
11481161
df_metrics: ExecutionPlanMetricsSet::new(),
11491162
layout_readers: Default::default(),
11501163
natural_splits: Default::default(),
1164+
file_schemas: Default::default(),
11511165
has_output_ordering: false,
11521166
expression_convertor: Arc::new(DefaultExpressionConvertor::default()),
11531167
file_metadata_cache: None,
@@ -1235,6 +1249,7 @@ mod tests {
12351249
df_metrics: ExecutionPlanMetricsSet::new(),
12361250
layout_readers: Default::default(),
12371251
natural_splits: Default::default(),
1252+
file_schemas: Default::default(),
12381253
has_output_ordering: false,
12391254
expression_convertor: Arc::new(DefaultExpressionConvertor::default()),
12401255
file_metadata_cache: None,
@@ -1389,6 +1404,7 @@ mod tests {
13891404
df_metrics: ExecutionPlanMetricsSet::new(),
13901405
layout_readers: Default::default(),
13911406
natural_splits: Default::default(),
1407+
file_schemas: Default::default(),
13921408
has_output_ordering: false,
13931409
expression_convertor: Arc::new(DefaultExpressionConvertor::default()),
13941410
file_metadata_cache: None,
@@ -1449,6 +1465,7 @@ mod tests {
14491465
df_metrics: ExecutionPlanMetricsSet::new(),
14501466
layout_readers: Default::default(),
14511467
natural_splits: Default::default(),
1468+
file_schemas: Default::default(),
14521469
has_output_ordering: false,
14531470
expression_convertor: Arc::new(DefaultExpressionConvertor::default()),
14541471
file_metadata_cache: None,
@@ -1656,6 +1673,7 @@ mod tests {
16561673
df_metrics: ExecutionPlanMetricsSet::new(),
16571674
layout_readers: Default::default(),
16581675
natural_splits: Default::default(),
1676+
file_schemas: Default::default(),
16591677
has_output_ordering: false,
16601678
expression_convertor: Arc::new(DefaultExpressionConvertor::default()),
16611679
file_metadata_cache: None,

‎vortex-datafusion/src/persistent/source.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::fmt::Formatter;
55
use std::sync::Arc;
66
use std::sync::Weak;
77

8+
use arrow_schema::SchemaRef;
89
use datafusion_common::Result as DFResult;
910
use datafusion_common::config::ConfigOptions;
1011
use datafusion_common::tree_node::TreeNodeRecursion;
@@ -198,6 +199,8 @@ pub struct VortexSource {
198199
layout_readers: Arc<DashMap<Path, Weak<dyn LayoutReader>>>,
199200
/// Shared full-file natural splits keyed by path.
200201
natural_splits: Arc<DashMap<Path, Arc<NaturalSplits>>>,
202+
/// Shared physical Arrow schemas of each file, keyed by path.
203+
file_schemas: Arc<DashMap<Path, SchemaRef>>,
201204
expression_convertor: Arc<dyn ExpressionConvertor>,
202205
pub(crate) vortex_reader_factory: Option<Arc<dyn VortexReaderFactory>>,
203206
pub(crate) ordered: bool,
@@ -231,6 +234,7 @@ impl VortexSource {
231234
df_metrics: Default::default(),
232235
layout_readers: Arc::new(DashMap::default()),
233236
natural_splits: Arc::new(DashMap::default()),
237+
file_schemas: Arc::new(DashMap::default()),
234238
expression_convertor,
235239
vortex_reader_factory: None,
236240
vx_metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
@@ -355,6 +359,7 @@ impl VortexSource {
355359
df_metrics: self.df_metrics.clone(),
356360
layout_readers: Arc::clone(&self.layout_readers),
357361
natural_splits: Arc::clone(&self.natural_splits),
362+
file_schemas: Arc::clone(&self.file_schemas),
358363
has_output_ordering: !base_config.output_ordering.is_empty() || self.ordered,
359364
expression_convertor: Arc::clone(&self.expression_convertor),
360365
file_metadata_cache: self.file_metadata_cache.clone(),

‎vortex-file/src/footer/mod.rs‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ pub struct Footer {
6161
segments: Arc<[SegmentSpec]>,
6262
statistics: Option<FileStatistics>,
6363
metadata: Arc<[(String, SegmentSpec)]>,
64+
// `segments` followed by the `metadata` segments, which is the segment table a file is opened
65+
// with. Precomputed so opening a file with a cached footer does not copy every segment spec.
66+
segments_with_metadata: Arc<[SegmentSpec]>,
67+
// The largest end offset of any segment in `segments`, or `None` if one overflows `u64`.
68+
// Precomputed so validating a cached footer against a file size does not walk every segment.
69+
// Writers never overlap segments, so in a valid file this is the last segment's end, but only
70+
// their order is checked when reading, and this guards against malformed footers.
71+
segments_end: Option<u64>,
6472
// The specific arrays used within the file, in the order they were registered.
6573
array_read_ctx: ReadContext,
6674
// The approximate size of the footer in bytes, used for caching and memory management.
@@ -76,6 +84,8 @@ impl Footer {
7684
) -> Self {
7785
Self {
7886
root_layout,
87+
segments_with_metadata: Arc::clone(&segments),
88+
segments_end: segments_end(&segments),
7989
segments,
8090
statistics,
8191
metadata: Arc::from([]),
@@ -148,6 +158,8 @@ impl Footer {
148158

149159
Ok(Self {
150160
root_layout,
161+
segments_with_metadata: segments_with_metadata(&segments, &metadata),
162+
segments_end: segments_end(&segments),
151163
segments,
152164
statistics,
153165
metadata,
@@ -186,16 +198,13 @@ impl Footer {
186198
}
187199

188200
pub(crate) fn with_metadata_segments(mut self, metadata: Arc<[(String, SegmentSpec)]>) -> Self {
201+
self.segments_with_metadata = segments_with_metadata(&self.segments, &metadata);
189202
self.metadata = metadata;
190203
self
191204
}
192205

193206
pub(crate) fn segment_specs_with_metadata(&self) -> Arc<[SegmentSpec]> {
194-
self.segments
195-
.iter()
196-
.copied()
197-
.chain(self.metadata.iter().map(|(_, segment)| *segment))
198-
.collect()
207+
Arc::clone(&self.segments_with_metadata)
199208
}
200209

201210
/// Computes the compressed size in bytes of every field in the file, keyed by field path.
@@ -224,6 +233,10 @@ impl Footer {
224233

225234
/// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
226235
pub(crate) fn validate_file_size(&self, file_size: u64) -> VortexResult<()> {
236+
if self.segments_end.is_some_and(|end| end <= file_size) {
237+
return Ok(());
238+
}
239+
// Walk the segments only to name the offending one in the error.
227240
validate_segments_within_file(&self.segments, file_size)
228241
}
229242

@@ -238,6 +251,26 @@ impl Footer {
238251
}
239252
}
240253

254+
fn segments_with_metadata(
255+
segments: &Arc<[SegmentSpec]>,
256+
metadata: &[(String, SegmentSpec)],
257+
) -> Arc<[SegmentSpec]> {
258+
if metadata.is_empty() {
259+
return Arc::clone(segments);
260+
}
261+
segments
262+
.iter()
263+
.copied()
264+
.chain(metadata.iter().map(|(_, segment)| *segment))
265+
.collect()
266+
}
267+
268+
fn segments_end(segments: &[SegmentSpec]) -> Option<u64> {
269+
segments.iter().try_fold(0u64, |end, segment| {
270+
Some(end.max(segment.offset.checked_add(u64::from(segment.length))?))
271+
})
272+
}
273+
241274
/// Validate that every segment declared in the footer lies within a file of `file_size` bytes.
242275
///
243276
/// A corrupt or malicious file can declare a segment whose offset or length extends past the end
@@ -293,4 +326,29 @@ mod tests {
293326
let err = validate_segments_within_file(&[segment(u64::MAX, 1)], u64::MAX).unwrap_err();
294327
assert!(err.to_string().contains("past the end"), "{err}");
295328
}
329+
330+
#[test]
331+
fn segments_end_covers_overlapping_segments() {
332+
// Valid files never overlap segments, but a malformed footer may declare an earlier
333+
// segment that ends after a later one, and the end must cover it so that file size
334+
// validation still rejects it.
335+
assert_eq!(
336+
segments_end(&[segment(0, 300), segment(100, 50)]),
337+
Some(300)
338+
);
339+
assert_eq!(segments_end(&[]), Some(0));
340+
assert_eq!(segments_end(&[segment(0, 1), segment(u64::MAX, 1)]), None);
341+
}
342+
343+
#[test]
344+
fn segments_with_metadata_appends_metadata_segments() {
345+
let segments: Arc<[SegmentSpec]> = Arc::from([segment(0, 10), segment(10, 20)]);
346+
347+
let without = segments_with_metadata(&segments, &[]);
348+
assert!(Arc::ptr_eq(&without, &segments));
349+
350+
let with = segments_with_metadata(&segments, &[("key".to_string(), segment(30, 5))]);
351+
let spans: Vec<_> = with.iter().map(|s| (s.offset, s.length)).collect();
352+
assert_eq!(spans, [(0, 10), (10, 20), (30, 5)]);
353+
}
296354
}

0 commit comments

Comments
 (0)