@@ -10,6 +10,7 @@ use arrow_schema::Schema;
1010use datafusion_common:: DataFusionError ;
1111use datafusion_common:: Result as DFResult ;
1212use datafusion_common:: ScalarValue ;
13+ use datafusion_common:: Statistics ;
1314use datafusion_common:: arrow:: array:: AsArray ;
1415use datafusion_common:: arrow:: array:: RecordBatch ;
1516use datafusion_common:: exec_datafusion_err;
@@ -23,11 +24,13 @@ use datafusion_physical_expr::PhysicalExprRef;
2324use datafusion_physical_expr:: projection:: ProjectionExprs ;
2425use datafusion_physical_expr:: simplifier:: PhysicalExprSimplifier ;
2526use datafusion_physical_expr:: split_conjunction;
27+ use datafusion_physical_expr:: utils:: collect_columns;
2628use datafusion_physical_expr:: utils:: reassign_expr_columns;
2729use datafusion_physical_expr_adapter:: PhysicalExprAdapterFactory ;
2830use 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 ;
3134use datafusion_pruning:: FilePruner ;
3235use futures:: FutureExt ;
3336use 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) ]
566563mod 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