Skip to content

Commit 028151b

Browse files
committed
Some DF fixes
Signed-off-by: Adam Gutglick <adam@spiraldb.com>
1 parent dd4ab89 commit 028151b

3 files changed

Lines changed: 228 additions & 70 deletions

File tree

vortex-datafusion/src/persistent/opener.rs

Lines changed: 168 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use arrow_schema::Schema;
1010
use datafusion_common::DataFusionError;
1111
use datafusion_common::Result as DFResult;
1212
use datafusion_common::ScalarValue;
13+
use datafusion_common::Statistics;
1314
use datafusion_common::arrow::array::AsArray;
1415
use datafusion_common::arrow::array::RecordBatch;
1516
use datafusion_common::exec_datafusion_err;
@@ -23,11 +24,13 @@ use datafusion_physical_expr::PhysicalExprRef;
2324
use datafusion_physical_expr::projection::ProjectionExprs;
2425
use datafusion_physical_expr::simplifier::PhysicalExprSimplifier;
2526
use datafusion_physical_expr::split_conjunction;
27+
use datafusion_physical_expr::utils::collect_columns;
2628
use datafusion_physical_expr::utils::reassign_expr_columns;
2729
use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory;
2830
use datafusion_physical_expr_adapter::replace_columns_with_literals;
29-
use datafusion_physical_expr_common::physical_expr::is_dynamic_physical_expr;
30-
use datafusion_physical_plan::metrics::Count;
31+
use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
32+
use datafusion_physical_plan::metrics::MetricBuilder;
33+
use datafusion_physical_plan::metrics::MetricCategory;
3134
use datafusion_pruning::FilePruner;
3235
use futures::FutureExt;
3336
use futures::StreamExt;
@@ -83,12 +86,12 @@ pub(crate) struct VortexOpener {
8386
/// This is the table's schema without partition columns. It may contain fields which do
8487
/// not exist in the file, and are supplied by the `schema_adapter_factory`.
8588
pub table_schema: TableSchema,
86-
/// A hint for the desired row count of record batches returned from the scan.
87-
pub batch_size: usize,
8889
/// If provided, the scan will not return more than this many rows.
8990
pub limit: Option<u64>,
9091
/// A metrics object for tracking performance of the scan.
9192
pub metrics_registry: Arc<dyn MetricsRegistry>,
93+
/// DataFusion-native metrics exposed through `DataSourceExec`.
94+
pub df_metrics: ExecutionPlanMetricsSet,
9295
/// A shared cache of file readers.
9396
///
9497
/// To save on the overhead of reparsing FlatBuffers and rebuilding the layout tree, we cache
@@ -123,12 +126,11 @@ impl FileOpener for VortexOpener {
123126
let reader =
124127
InstrumentedReadAt::new_with_labels(reader, metrics_registry.as_ref(), labels.clone());
125128

126-
let file_pruning_predicate = self.file_pruning_predicate.clone();
129+
let mut file_pruning_predicate = self.file_pruning_predicate.clone();
127130
let expr_adapter_factory = Arc::clone(&self.expr_adapter_factory);
128131
let file_metadata_cache = self.file_metadata_cache.clone();
129132

130133
let unified_file_schema = Arc::clone(self.table_schema.file_schema());
131-
let batch_size = self.batch_size;
132134
let limit = self.limit;
133135
let layout_readers = Arc::clone(&self.layout_readers);
134136
let natural_split_ranges = Arc::clone(&self.natural_split_ranges);
@@ -138,6 +140,10 @@ impl FileOpener for VortexOpener {
138140
let expr_convertor = Arc::clone(&self.expression_convertor);
139141
let projection_pushdown = self.projection_pushdown;
140142

143+
let predicate_creation_errors = MetricBuilder::new(&self.df_metrics)
144+
.with_category(MetricCategory::Rows)
145+
.global_counter("num_predicate_creation_errors");
146+
141147
// Replace column access for partition columns with literals
142148
#[expect(clippy::disallowed_types)]
143149
let literal_value_cols = self
@@ -149,31 +155,44 @@ impl FileOpener for VortexOpener {
149155
.zip(file.partition_values.clone())
150156
.collect::<std::collections::HashMap<String, ScalarValue>>();
151157

158+
let predicate_uses_partition_columns =
159+
file_pruning_predicate.as_ref().is_some_and(|predicate| {
160+
collect_columns(predicate)
161+
.iter()
162+
.any(|column| literal_value_cols.contains_key(column.name()))
163+
});
164+
152165
if !literal_value_cols.is_empty() {
153166
projection = projection.try_map_exprs(|expr| {
154167
replace_columns_with_literals(Arc::clone(&expr), &literal_value_cols)
155168
})?;
156169
filter = filter
157170
.map(|p| replace_columns_with_literals(p, &literal_value_cols))
158171
.transpose()?;
172+
file_pruning_predicate = file_pruning_predicate
173+
.map(|p| replace_columns_with_literals(p, &literal_value_cols))
174+
.transpose()?;
159175
}
160176

161177
Ok(async move {
162-
// Create FilePruner when we have a predicate and either dynamic expressions
163-
// or file statistics available. The pruner can eliminate files without
164-
// opening them based on File-level statistics (min/max values per column)
178+
// FilePruner requires a statistics object even when the rewritten predicate
179+
// only contains partition literals. Supply unknown file-column statistics in
180+
// that case so static and dynamic partition predicates can still prune.
181+
let synthetic_statistics = (!file.has_statistics() && predicate_uses_partition_columns)
182+
.then(|| {
183+
file.clone()
184+
.with_statistics(Arc::new(Statistics::new_unknown(&unified_file_schema)))
185+
});
186+
let pruning_file = synthetic_statistics.as_ref().unwrap_or(&file);
187+
165188
let mut file_pruner = file_pruning_predicate
166-
.filter(|p| {
167-
// Only create pruner if we have dynamic expressions or file statistics
168-
// to work with. Static predicates without stats won't benefit from pruning.
169-
is_dynamic_physical_expr(p) || file.has_statistics()
170-
})
189+
.filter(|_| file.has_statistics() || predicate_uses_partition_columns)
171190
.and_then(|predicate| {
172191
FilePruner::try_new(
173192
Arc::clone(&predicate),
174193
&unified_file_schema,
175-
&file,
176-
Count::default(),
194+
pruning_file,
195+
predicate_creation_errors,
177196
)
178197
});
179198

@@ -424,34 +443,12 @@ impl FileOpener for VortexOpener {
424443
})
425444
.into_stream()
426445
.map_err(|e| exec_datafusion_err!("Failed to create Vortex stream: {e}"))?
427-
.map_ok(move |rb| {
428-
// We try and slice the stream into respecting datafusion's configured batch size.
429-
stream::iter(
430-
(0..rb.num_rows().div_ceil(batch_size * 2))
431-
.flat_map(move |block_idx| {
432-
let offset = block_idx * batch_size * 2;
433-
434-
// If we have less than two batches worth of rows left, we keep them together as a single batch.
435-
if rb.num_rows() - offset < 2 * batch_size {
436-
let length = rb.num_rows() - offset;
437-
[Some(rb.slice(offset, length)), None].into_iter()
438-
} else {
439-
let first = rb.slice(offset, batch_size);
440-
let second = rb.slice(offset + batch_size, batch_size);
441-
[Some(first), Some(second)].into_iter()
442-
}
443-
})
444-
.flatten()
445-
.map(Ok),
446-
)
447-
})
448446
.map_err(move |e: VortexError| {
449447
DataFusionError::External(Box::new(e.with_context(format!(
450448
"Failed to read Vortex file: {}",
451449
file.object_meta.location
452450
))))
453451
})
454-
.try_flatten()
455452
.map(move |batch| {
456453
if projector.projection().as_ref().is_empty() {
457454
batch
@@ -564,6 +561,7 @@ fn split_midpoint_to_byte(split_range: &Range<u64>, row_count: u64, total_size:
564561

565562
#[cfg(test)]
566563
mod tests {
564+
use std::fmt;
567565
use std::sync::Arc;
568566
use std::sync::LazyLock;
569567

@@ -585,9 +583,12 @@ mod tests {
585583
use datafusion::physical_expr::planner::logical2physical;
586584
use datafusion::physical_expr_adapter::DefaultPhysicalExprAdapterFactory;
587585
use datafusion::scalar::ScalarValue;
586+
use datafusion_common::stats::Precision;
588587
use datafusion_execution::cache::DefaultFilesMetadataCache;
589588
use datafusion_expr::Operator;
589+
use datafusion_physical_expr::PhysicalExpr;
590590
use datafusion_physical_expr::expressions as df_expr;
591+
use datafusion_physical_expr::expressions::DynamicFilterPhysicalExpr;
591592
use datafusion_physical_expr::projection::ProjectionExpr;
592593
use insta::assert_snapshot;
593594
use itertools::Itertools;
@@ -612,6 +613,53 @@ mod tests {
612613

613614
static SESSION: LazyLock<VortexSession> = LazyLock::new(VortexSession::default);
614615

616+
#[derive(Debug, Eq, Hash, PartialEq)]
617+
struct SnapshotErrorExpr;
618+
619+
impl fmt::Display for SnapshotErrorExpr {
620+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
621+
write!(f, "snapshot_error")
622+
}
623+
}
624+
625+
impl PhysicalExpr for SnapshotErrorExpr {
626+
fn data_type(&self, _input_schema: &Schema) -> DFResult<DataType> {
627+
Ok(DataType::Boolean)
628+
}
629+
630+
fn nullable(&self, _input_schema: &Schema) -> DFResult<bool> {
631+
Ok(false)
632+
}
633+
634+
fn fmt_sql(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635+
fmt::Display::fmt(self, f)
636+
}
637+
638+
fn evaluate(&self, _batch: &RecordBatch) -> DFResult<datafusion_expr::ColumnarValue> {
639+
Err(DataFusionError::Internal(
640+
"intentional snapshot error".to_owned(),
641+
))
642+
}
643+
644+
fn children(&self) -> Vec<&PhysicalExprRef> {
645+
Vec::new()
646+
}
647+
648+
fn with_new_children(
649+
self: Arc<Self>,
650+
children: Vec<PhysicalExprRef>,
651+
) -> DFResult<PhysicalExprRef> {
652+
assert!(children.is_empty());
653+
Ok(self)
654+
}
655+
656+
fn snapshot(&self) -> DFResult<Option<PhysicalExprRef>> {
657+
Err(DataFusionError::Internal(
658+
"intentional snapshot error".to_owned(),
659+
))
660+
}
661+
}
662+
615663
#[rstest]
616664
#[case(0..3, 10, vec![0..2, 2..5, 5..10], Some(0..2))]
617665
#[case(3..7, 10, vec![0..2, 2..5, 5..10], Some(2..5))]
@@ -700,9 +748,9 @@ mod tests {
700748
file_pruning_predicate: None,
701749
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),
702750
table_schema,
703-
batch_size: 100,
704751
limit: None,
705752
metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
753+
df_metrics: ExecutionPlanMetricsSet::new(),
706754
layout_readers: Default::default(),
707755
natural_split_ranges: Default::default(),
708756
has_output_ordering: false,
@@ -766,6 +814,82 @@ mod tests {
766814
Ok(())
767815
}
768816

817+
#[tokio::test]
818+
async fn test_file_pruning_replaces_partition_columns_without_file_statistics()
819+
-> anyhow::Result<()> {
820+
let object_store = Arc::new(InMemory::new()) as Arc<dyn ObjectStore>;
821+
let file_schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
822+
let table_schema = TableSchema::new(
823+
Arc::clone(&file_schema),
824+
vec![Arc::new(Field::new("part", DataType::Int32, false))],
825+
);
826+
827+
let partition_column = Arc::new(df_expr::Column::new("part", 1)) as PhysicalExprRef;
828+
let predicate = Arc::new(df_expr::BinaryExpr::new(
829+
Arc::clone(&partition_column),
830+
Operator::Gt,
831+
df_expr::lit(ScalarValue::Int32(Some(1))),
832+
)) as PhysicalExprRef;
833+
let dynamic_predicate = Arc::new(DynamicFilterPhysicalExpr::new(
834+
vec![partition_column],
835+
predicate,
836+
)) as PhysicalExprRef;
837+
838+
let mut opener = make_opener(object_store, table_schema, None);
839+
opener.file_pruning_predicate = Some(dynamic_predicate);
840+
let df_metrics = opener.df_metrics.clone();
841+
842+
// The file does not exist and has no statistics. Replacing `part` with 1
843+
// makes the predicate false, so pruning must happen before any file I/O.
844+
let mut file = PartitionedFile::new("missing.vortex", 1);
845+
file.partition_values = vec![ScalarValue::Int32(Some(1))];
846+
let batches = opener.open(file)?.await?.try_collect::<Vec<_>>().await?;
847+
848+
assert!(batches.is_empty());
849+
assert_eq!(
850+
df_metrics
851+
.clone_inner()
852+
.sum_by_name("num_predicate_creation_errors")
853+
.map(|metric| metric.as_usize()),
854+
Some(0)
855+
);
856+
857+
Ok(())
858+
}
859+
860+
#[tokio::test]
861+
async fn test_file_pruning_creation_errors_are_reported() -> anyhow::Result<()> {
862+
let object_store = Arc::new(InMemory::new()) as Arc<dyn ObjectStore>;
863+
let file_path = "metrics/file.vortex";
864+
let batch = record_batch!(("a", Int32, vec![Some(1), Some(2), Some(3)])).unwrap();
865+
let data_size =
866+
write_arrow_to_vortex(Arc::clone(&object_store), file_path, batch.clone()).await?;
867+
let mut statistics = Statistics::new_unknown(batch.schema().as_ref());
868+
statistics.column_statistics[0].null_count = Precision::Exact(0);
869+
let file = PartitionedFile::new(file_path, data_size).with_statistics(Arc::new(statistics));
870+
871+
let mut opener = make_opener(
872+
object_store,
873+
TableSchema::from_file_schema(batch.schema()),
874+
None,
875+
);
876+
opener.file_pruning_predicate = Some(Arc::new(SnapshotErrorExpr));
877+
let df_metrics = opener.df_metrics.clone();
878+
879+
let batches = opener.open(file)?.await?.try_collect::<Vec<_>>().await?;
880+
881+
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 3);
882+
assert_eq!(
883+
df_metrics
884+
.clone_inner()
885+
.sum_by_name("num_predicate_creation_errors")
886+
.map(|metric| metric.as_usize()),
887+
Some(1)
888+
);
889+
890+
Ok(())
891+
}
892+
769893
#[tokio::test]
770894
async fn test_open_empty_file() -> anyhow::Result<()> {
771895
use futures::TryStreamExt;
@@ -872,9 +996,9 @@ mod tests {
872996
file_pruning_predicate: None,
873997
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),
874998
table_schema: table_schema.clone(),
875-
batch_size: 100,
876999
limit: None,
8771000
metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
1001+
df_metrics: ExecutionPlanMetricsSet::new(),
8781002
layout_readers: Default::default(),
8791003
natural_split_ranges: Default::default(),
8801004
has_output_ordering: false,
@@ -959,9 +1083,9 @@ mod tests {
9591083
file_pruning_predicate: None,
9601084
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),
9611085
table_schema: TableSchema::from_file_schema(Arc::clone(&table_schema)),
962-
batch_size: 100,
9631086
limit: None,
9641087
metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
1088+
df_metrics: ExecutionPlanMetricsSet::new(),
9651089
layout_readers: Default::default(),
9661090
natural_split_ranges: Default::default(),
9671091
has_output_ordering: false,
@@ -1116,9 +1240,9 @@ mod tests {
11161240
file_pruning_predicate: None,
11171241
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),
11181242
table_schema: table_schema.clone(),
1119-
batch_size: 100,
11201243
limit: None,
11211244
metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
1245+
df_metrics: ExecutionPlanMetricsSet::new(),
11221246
layout_readers: Default::default(),
11231247
natural_split_ranges: Default::default(),
11241248
has_output_ordering: false,
@@ -1176,9 +1300,9 @@ mod tests {
11761300
file_pruning_predicate: None,
11771301
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),
11781302
table_schema: TableSchema::from_file_schema(schema),
1179-
batch_size: 100,
11801303
limit: None,
11811304
metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
1305+
df_metrics: ExecutionPlanMetricsSet::new(),
11821306
layout_readers: Default::default(),
11831307
natural_split_ranges: Default::default(),
11841308
has_output_ordering: false,
@@ -1385,9 +1509,9 @@ mod tests {
13851509
file_pruning_predicate: None,
13861510
expr_adapter_factory: Arc::new(DefaultPhysicalExprAdapterFactory),
13871511
table_schema,
1388-
batch_size: 100,
13891512
limit: None,
13901513
metrics_registry: Arc::new(DefaultMetricsRegistry::default()),
1514+
df_metrics: ExecutionPlanMetricsSet::new(),
13911515
layout_readers: Default::default(),
13921516
natural_split_ranges: Default::default(),
13931517
has_output_ordering: false,

0 commit comments

Comments
 (0)