@@ -370,20 +370,15 @@ impl FileOpener for VortexOpener {
370370 if byte_range. start == 0 && byte_range. end == file. object_meta . size {
371371 None
372372 } else {
373- // Distribute V2 natural split ranges across DataFusion's byte-range
374- // file_groups. V2 prepares morsels from these same split ranges, so each
375- // DataFusion partition owns whole V2 morsels instead of slicing through
376- // chunk boundaries and forcing duplicate decode work.
377- let split_ranges = scan_plan_split_ranges_for_file (
378- natural_split_ranges. as_ref ( ) ,
379- & file. object_meta . location ,
380- & vxf,
381- ) ?;
382-
383- let Some ( row_range) = split_aligned_row_range (
373+ // DataFusion partitions a single file by byte ranges. V2 may expose only
374+ // coarse top-level split hints, so assigning whole natural splits here can
375+ // collapse many byte ranges into a few row ranges. Slice proportionally by
376+ // row count; the V2 scan plan will still split the resulting row range into
377+ // layout-aware morsels during preparation.
378+ let Some ( row_range) = byte_range_to_row_range (
384379 byte_range,
385380 file. object_meta . size ,
386- split_ranges . as_ref ( ) ,
381+ vxf . row_count ( ) ,
387382 ) else {
388383 return Ok ( stream:: empty ( ) . boxed ( ) ) ;
389384 } ;
@@ -662,43 +657,34 @@ fn compute_natural_split_ranges(layout_reader: &dyn LayoutReader) -> DFResult<Ar
662657 Ok ( split_points. into ( ) )
663658}
664659
665- /// Get or create V2 natural split ranges for a file. These ranges are produced by the file's V2
666- /// ScanPlan root and cached per path so every byte-range partition sees the same row boundaries.
667- fn scan_plan_split_ranges_for_file (
668- split_ranges_cache : & DashMap < Path , Arc < [ Range < u64 > ] > > ,
669- path : & Path ,
670- file : & VortexFile ,
671- ) -> DFResult < Arc < [ Range < u64 > ] > > {
672- if let Some ( ranges) = split_ranges_cache. get ( path) {
673- return Ok ( Arc :: clone ( ranges. value ( ) ) ) ;
660+ fn byte_range_to_row_range (
661+ byte_range : Range < u64 > ,
662+ total_size : u64 ,
663+ row_count : u64 ,
664+ ) -> Option < Range < u64 > > {
665+ if byte_range. start >= byte_range. end || total_size == 0 || row_count == 0 {
666+ return None ;
674667 }
675668
676- let ranges: Arc < [ Range < u64 > ] > = file
677- . scan_plan_splits ( )
678- . map_err ( |e| exec_datafusion_err ! ( "Failed to compute Vortex scan2 splits: {e}" ) ) ?
679- . into ( ) ;
680- tracing:: debug!(
681- target: "vortex_datafusion::persistent::opener" ,
682- path = %path,
683- split_count = ranges. len( ) ,
684- first_split = ?ranges. first( ) ,
685- last_split = ?ranges. last( ) ,
686- "scan2 file split ranges"
687- ) ;
688- tracing:: trace!(
689- target: "vortex_datafusion::persistent::opener" ,
690- path = %path,
691- ?ranges,
692- "scan2 file split range detail"
693- ) ;
694-
695- match split_ranges_cache. entry ( path. clone ( ) ) {
696- Entry :: Occupied ( entry) => Ok ( Arc :: clone ( entry. get ( ) ) ) ,
697- Entry :: Vacant ( entry) => {
698- entry. insert ( Arc :: clone ( & ranges) ) ;
699- Ok ( ranges)
700- }
669+ let start_byte = byte_range. start . min ( total_size) ;
670+ let end_byte = byte_range. end . min ( total_size) ;
671+ if start_byte >= end_byte {
672+ return None ;
701673 }
674+
675+ let start = byte_to_row ( start_byte, total_size, row_count) ;
676+ let end = if end_byte == total_size {
677+ row_count
678+ } else {
679+ byte_to_row ( end_byte, total_size, row_count)
680+ } ;
681+
682+ ( start < end) . then_some ( start..end)
683+ }
684+
685+ fn byte_to_row ( byte : u64 , total_size : u64 , row_count : u64 ) -> u64 {
686+ let row = ( u128:: from ( byte) * u128:: from ( row_count) ) / u128:: from ( total_size) ;
687+ u64:: try_from ( row) . vortex_expect ( "byte-to-row projection should fit into u64" )
702688}
703689
704690/// Translate a DataFusion byte range to the contiguous natural split ranges it owns.
@@ -807,6 +793,57 @@ mod tests {
807793
808794 static SESSION : LazyLock < VortexSession > = LazyLock :: new ( VortexSession :: default) ;
809795
796+ #[ rstest]
797+ #[ case( 0 ..10 , 100 , 50 , Some ( 0 ..5 ) ) ]
798+ #[ case( 10 ..20 , 100 , 50 , Some ( 5 ..10 ) ) ]
799+ #[ case( 90 ..100 , 100 , 50 , Some ( 45 ..50 ) ) ]
800+ #[ case( 100 ..110 , 100 , 50 , None ) ]
801+ #[ case( 0 ..1 , 100 , 50 , None ) ]
802+ fn test_byte_range_to_row_range (
803+ #[ case] byte_range : Range < u64 > ,
804+ #[ case] total_size : u64 ,
805+ #[ case] row_count : u64 ,
806+ #[ case] expected : Option < Range < u64 > > ,
807+ ) {
808+ assert_eq ! (
809+ byte_range_to_row_range( byte_range, total_size, row_count) ,
810+ expected
811+ ) ;
812+ }
813+
814+ #[ test]
815+ fn test_byte_ranges_cover_rows_exactly_once ( ) {
816+ let total_size = 179_114_706 ;
817+ let row_count = 6_001_215 ;
818+ let partitions = 18 ;
819+ let byte_ranges = ( 0 ..partitions)
820+ . map ( |idx| {
821+ let start = idx * total_size / partitions;
822+ let end = ( idx + 1 ) * total_size / partitions;
823+ start..end
824+ } )
825+ . collect :: < Vec < _ > > ( ) ;
826+
827+ let row_ranges = byte_ranges
828+ . into_iter ( )
829+ . filter_map ( |byte_range| byte_range_to_row_range ( byte_range, total_size, row_count) )
830+ . collect :: < Vec < _ > > ( ) ;
831+
832+ assert_eq ! ( row_ranges. len( ) , partitions as usize ) ;
833+ assert_eq ! ( row_ranges. first( ) . map( |range| range. start) , Some ( 0 ) ) ;
834+ assert_eq ! ( row_ranges. last( ) . map( |range| range. end) , Some ( row_count) ) ;
835+ assert_eq ! (
836+ row_ranges
837+ . iter( )
838+ . map( |range| range. end - range. start)
839+ . sum:: <u64 >( ) ,
840+ row_count
841+ ) ;
842+ for ( left, right) in row_ranges. iter ( ) . tuple_windows ( ) {
843+ assert_eq ! ( left. end, right. start) ;
844+ }
845+ }
846+
810847 #[ rstest]
811848 #[ case( 0 ..3 , 10 , vec![ 0 ..2 , 2 ..5 , 5 ..10 ] , Some ( 0 ..2 ) ) ]
812849 #[ case( 3 ..7 , 10 , vec![ 0 ..2 , 2 ..5 , 5 ..10 ] , Some ( 2 ..5 ) ) ]
0 commit comments