@@ -24,7 +24,8 @@ use delta_kernel::{
2424 FileDataReadResultIterator , FileMeta , FileSize , JsonHandler , PredicateRef ,
2525} ;
2626use futures:: stream:: { self , BoxStream } ;
27- use futures:: { ready, StreamExt , TryStreamExt } ;
27+ use futures:: { ready, FutureExt , StreamExt , TryStreamExt } ;
28+ use tokio:: sync:: mpsc;
2829use url:: Url ;
2930
3031use crate :: executor:: TaskExecutor ;
@@ -42,6 +43,9 @@ pub struct DefaultJsonHandler<E: TaskExecutor> {
4243 /// Limit the number of rows per batch. That is, for batch_size = N, then each RecordBatch
4344 /// yielded by the stream will have at most N rows.
4445 batch_size : NonZero < usize > ,
46+ /// Number of ordered file chunks to parse concurrently. `None` means
47+ /// no parallelism (default).
48+ parallel_chunks : Option < NonZero < usize > > ,
4549}
4650
4751impl < E : TaskExecutor > DefaultJsonHandler < E > {
@@ -51,6 +55,7 @@ impl<E: TaskExecutor> DefaultJsonHandler<E> {
5155 task_executor,
5256 buffer_size : super :: DEFAULT_READ_BUFFER_SIZE ,
5357 batch_size : super :: DEFAULT_READ_BATCH_SIZE ,
58+ parallel_chunks : None ,
5459 }
5560 }
5661
@@ -81,6 +86,13 @@ impl<E: TaskExecutor> DefaultJsonHandler<E> {
8186 self . batch_size = batch_size;
8287 self
8388 }
89+
90+ /// Number of ordered file chunks to parse concurrently in [`Self::read_json_files`].
91+ /// `None` (the default) means no parallelism, no chunking.
92+ pub fn with_parallel_chunks ( mut self , parallel_chunks : Option < NonZero < usize > > ) -> Self {
93+ self . parallel_chunks = parallel_chunks;
94+ self
95+ }
8496}
8597
8698/// Internal async implementation of read_json_files
@@ -124,7 +136,102 @@ async fn read_json_files_impl(
124136 . try_flatten ( )
125137 . map_ok ( |e| -> Box < dyn EngineData > { Box :: new ( e) } ) ;
126138
127- Ok ( Box :: pin ( result_stream) )
139+ Ok ( result_stream. boxed ( ) )
140+ }
141+
142+ /// Parallel JSON read by splitting the ordered file list into chunks. Each chunk
143+ /// uses the original [`read_json_files_impl`] pipeline. The chunk pipelines run
144+ /// concurrently as tokio tasks and results are concatenated in order.
145+ ///
146+ /// Yield order is the same as [`read_json_files_impl`].
147+ async fn read_json_files_parallel_impl (
148+ store : Arc < DynObjectStore > ,
149+ files : Vec < FileMeta > ,
150+ physical_schema : SchemaRef ,
151+ predicate : Option < PredicateRef > ,
152+ batch_size : usize ,
153+ buffer_size : usize ,
154+ parallel_chunks : usize ,
155+ ) -> DeltaResult < BoxStream < ' static , DeltaResult < Box < dyn EngineData > > > > {
156+ if files. is_empty ( ) {
157+ return Ok ( Box :: pin ( stream:: empty ( ) ) ) ;
158+ }
159+
160+ let num_chunks = parallel_chunks. min ( files. len ( ) ) . max ( 1 ) ;
161+ let chunk_size = files. len ( ) . div_ceil ( num_chunks) ;
162+ let chunks: Vec < Vec < FileMeta > > = files
163+ . chunks ( chunk_size)
164+ . map ( |chunk| chunk. to_vec ( ) )
165+ . collect ( ) ;
166+ let num_chunks = chunks. len ( ) ;
167+ let per_chunk_buffer = ( buffer_size / num_chunks) . max ( 1 ) ;
168+
169+ // Spawn each chunk as a tokio task. Each task streams batches through a
170+ // channel as they are produced. We keep the JoinHandle so a panic becomes
171+ // JoinFailure instead of a silent hole in the file list.
172+ let channel_cap = per_chunk_buffer. saturating_mul ( 4 ) . max ( 1 ) ;
173+ let mut receivers = Vec :: new ( ) ;
174+ let mut handles = Vec :: new ( ) ;
175+ for chunk in chunks {
176+ let ( tx, rx) = mpsc:: channel :: < DeltaResult < Box < dyn EngineData > > > ( channel_cap) ;
177+ receivers. push ( rx) ;
178+
179+ let store = store. clone ( ) ;
180+ let physical_schema = physical_schema. clone ( ) ;
181+ let predicate = predicate. clone ( ) ;
182+
183+ let handle = tokio:: spawn ( async move {
184+ let result = read_json_files_impl (
185+ store,
186+ chunk,
187+ physical_schema,
188+ predicate,
189+ batch_size,
190+ per_chunk_buffer,
191+ )
192+ . await ;
193+ match result {
194+ Ok ( batch_stream) => {
195+ let mut batch_stream = std:: pin:: pin!( batch_stream) ;
196+ while let Some ( batch) = batch_stream. next ( ) . await {
197+ if tx. send ( batch) . await . is_err ( ) {
198+ // Consumer dropped the stream; stop this chunk.
199+ return ;
200+ }
201+ }
202+ }
203+ Err ( e) => {
204+ let _ = tx. send ( Err ( e) ) . await ;
205+ }
206+ }
207+ } ) ;
208+ handles. push ( handle) ;
209+ }
210+
211+ // Drain in chunk order. After each channel closes, join the task.
212+ let result_stream = stream:: iter ( receivers. into_iter ( ) . zip ( handles) )
213+ . flat_map ( |( rx, handle) | drain_chunk ( rx, handle) ) ;
214+
215+ Ok ( result_stream. boxed ( ) )
216+ }
217+
218+ /// Drain `rx` then join `handle`. A panicked task is `Error::JoinFailure`, not EOF.
219+ fn drain_chunk (
220+ rx : mpsc:: Receiver < DeltaResult < Box < dyn EngineData > > > ,
221+ handle : tokio:: task:: JoinHandle < ( ) > ,
222+ ) -> impl futures:: Stream < Item = DeltaResult < Box < dyn EngineData > > > {
223+ let batches = stream:: unfold ( rx, |mut rx| async {
224+ rx. recv ( ) . await . map ( |item| ( item, rx) )
225+ } ) ;
226+ let join = stream:: once ( async move { handle. await . map_err ( Error :: join_failure) } ) . filter_map (
227+ |result| async move {
228+ match result {
229+ Ok ( ( ) ) => None ,
230+ Err ( e) => Some ( Err ( e) ) ,
231+ }
232+ } ,
233+ ) ;
234+ batches. chain ( join)
128235}
129236
130237/// Internal async implementation of write_json_file
@@ -176,14 +283,31 @@ impl<E: TaskExecutor> JsonHandler for DefaultJsonHandler<E> {
176283 predicate : Option < PredicateRef > ,
177284 cancellation_token : Option < CancellationTokenRef > ,
178285 ) -> DeltaResult < FileDataReadResultIterator > {
179- let future = read_json_files_impl (
180- self . store . clone ( ) ,
181- files. to_vec ( ) ,
182- physical_schema,
183- predicate,
184- self . batch_size . get ( ) ,
185- self . buffer_size . get ( ) ,
186- ) ;
286+ let store = self . store . clone ( ) ;
287+ let files = files. to_vec ( ) ;
288+ let batch_size = self . batch_size . get ( ) ;
289+ let buffer_size = self . buffer_size . get ( ) ;
290+ let future = match self . parallel_chunks {
291+ Some ( parallel_chunks) => read_json_files_parallel_impl (
292+ store,
293+ files,
294+ physical_schema,
295+ predicate,
296+ batch_size,
297+ buffer_size,
298+ parallel_chunks. get ( ) ,
299+ )
300+ . boxed ( ) ,
301+ None => read_json_files_impl (
302+ store,
303+ files,
304+ physical_schema,
305+ predicate,
306+ batch_size,
307+ buffer_size,
308+ )
309+ . boxed ( ) ,
310+ } ;
187311 super :: stream_future_to_cancellable_iter (
188312 self . task_executor . clone ( ) ,
189313 future,
@@ -283,6 +407,7 @@ mod tests {
283407 use std:: path:: PathBuf ;
284408 use std:: sync:: { mpsc, Arc , Mutex } ;
285409 use std:: task:: Waker ;
410+ use std:: time:: Duration ;
286411
287412 use delta_kernel:: actions:: get_commit_schema;
288413 use delta_kernel:: arrow:: array:: { Array , AsArray , Int32Array , RecordBatch , StringArray } ;
@@ -305,6 +430,93 @@ mod tests {
305430 use super :: * ;
306431 use crate :: executor:: tokio:: { TokioBackgroundExecutor , TokioMultiThreadExecutor } ;
307432
433+ /// Wraps an inner store and sleeps on every ObjectStore API before forwarding.
434+ /// Completes whatever was requested; never waits for a path that was not asked for.
435+ #[ derive( Debug ) ]
436+ struct LatencyStore < T : ObjectStore > {
437+ inner : T ,
438+ delay : Duration ,
439+ }
440+
441+ impl < T : ObjectStore > LatencyStore < T > {
442+ fn new ( inner : T , delay : Duration ) -> Self {
443+ Self { inner, delay }
444+ }
445+
446+ async fn delay ( & self ) {
447+ if !self . delay . is_zero ( ) {
448+ tokio:: time:: sleep ( self . delay ) . await ;
449+ }
450+ }
451+ }
452+
453+ impl < T : ObjectStore > std:: fmt:: Display for LatencyStore < T > {
454+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
455+ write ! ( f, "LatencyStore(delay={:?})" , self . delay)
456+ }
457+ }
458+
459+ #[ async_trait:: async_trait]
460+ impl < T : ObjectStore > delta_kernel:: object_store:: ObjectStore for LatencyStore < T > {
461+ async fn put_opts (
462+ & self ,
463+ location : & Path ,
464+ payload : PutPayload ,
465+ opts : PutOptions ,
466+ ) -> Result < PutResult > {
467+ self . delay ( ) . await ;
468+ self . inner . put_opts ( location, payload, opts) . await
469+ }
470+
471+ async fn put_multipart_opts (
472+ & self ,
473+ location : & Path ,
474+ opts : PutMultipartOptions ,
475+ ) -> Result < Box < dyn MultipartUpload > > {
476+ self . delay ( ) . await ;
477+ self . inner . put_multipart_opts ( location, opts) . await
478+ }
479+
480+ async fn get_opts ( & self , location : & Path , options : GetOptions ) -> Result < GetResult > {
481+ self . delay ( ) . await ;
482+ self . inner . get_opts ( location, options) . await
483+ }
484+
485+ async fn get_ranges ( & self , location : & Path , ranges : & [ Range < u64 > ] ) -> Result < Vec < Bytes > > {
486+ self . delay ( ) . await ;
487+ self . inner . get_ranges ( location, ranges) . await
488+ }
489+
490+ fn delete_stream (
491+ & self ,
492+ locations : BoxStream < ' static , Result < Path > > ,
493+ ) -> BoxStream < ' static , Result < Path > > {
494+ self . inner . delete_stream ( locations)
495+ }
496+
497+ fn list ( & self , prefix : Option < & Path > ) -> BoxStream < ' static , Result < ObjectMeta > > {
498+ self . inner . list ( prefix)
499+ }
500+
501+ fn list_with_offset (
502+ & self ,
503+ prefix : Option < & Path > ,
504+ offset : & Path ,
505+ ) -> BoxStream < ' static , Result < ObjectMeta > > {
506+ self . inner . list_with_offset ( prefix, offset)
507+ }
508+
509+ async fn list_with_delimiter ( & self , prefix : Option < & Path > ) -> Result < ListResult > {
510+ self . delay ( ) . await ;
511+ self . inner . list_with_delimiter ( prefix) . await
512+ }
513+
514+ async fn copy_opts ( & self , from : & Path , to : & Path , options : CopyOptions ) -> Result < ( ) > {
515+ self . delay ( ) . await ;
516+ self . inner . copy_opts ( from, to, options) . await
517+ }
518+ }
519+
308520 /// Store wrapper that wraps an inner store to guarantee the ordering of GET requests. Note
309521 /// that since the keys are resolved in order, requests to subsequent keys in the order will
310522 /// block until the earlier keys are requested.
@@ -834,6 +1046,89 @@ mod tests {
8341046 }
8351047 }
8361048
1049+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 3 ) ]
1050+ async fn test_read_json_files_parallel_ordering ( ) {
1051+ // Parallel impl does not issue every GET up front, so OrderedGetStore
1052+ // would hang. LatencyStore delays each requested GET and always completes it.
1053+ const N : i32 = 10_000 ;
1054+ let ordered_paths: Vec < Path > = ( 0 ..N )
1055+ . map ( |i| Path :: from ( format ! ( "test/path{i}" ) ) )
1056+ . collect ( ) ;
1057+
1058+ let memory_store = InMemory :: new ( ) ;
1059+ for ( i, path) in ordered_paths. iter ( ) . enumerate ( ) {
1060+ memory_store
1061+ . put ( path, Bytes :: from ( format ! ( "{{\" val\" : {i}}}" ) ) . into ( ) )
1062+ . await
1063+ . unwrap ( ) ;
1064+ }
1065+
1066+ let store = Arc :: new ( LatencyStore :: new ( memory_store, Duration :: from_millis ( 1 ) ) ) ;
1067+ let ordered_file_meta: Vec < _ > = ordered_paths
1068+ . iter ( )
1069+ . map ( |path| {
1070+ let store = store. clone ( ) ;
1071+ async move {
1072+ let url = Url :: parse ( & format ! ( "memory:/{path}" ) ) . unwrap ( ) ;
1073+ let location = Path :: from ( path. as_ref ( ) ) ;
1074+ let meta = store. head ( & location) . await . unwrap ( ) ;
1075+ FileMeta {
1076+ location : url,
1077+ last_modified : meta. last_modified . timestamp_millis ( ) ,
1078+ size : meta. size ,
1079+ }
1080+ }
1081+ } )
1082+ . collect ( ) ;
1083+ let files = future:: join_all ( ordered_file_meta) . await ;
1084+
1085+ let handler = DefaultJsonHandler :: new (
1086+ store,
1087+ Arc :: new ( TokioMultiThreadExecutor :: new (
1088+ tokio:: runtime:: Handle :: current ( ) ,
1089+ ) ) ,
1090+ )
1091+ . with_buffer_size ( NonZero :: new ( 1000 ) . expect ( "buffer_size is non-zero" ) )
1092+ . with_parallel_chunks ( NonZero :: new ( 100 ) ) ;
1093+ let physical_schema = schema_ref ! { nullable "val" : INTEGER } ;
1094+ let data: Vec < RecordBatch > = handler
1095+ . read_json_files ( & files, physical_schema, None )
1096+ . unwrap ( )
1097+ . map_ok ( into_record_batch)
1098+ . try_collect ( )
1099+ . unwrap ( ) ;
1100+
1101+ let all_values: Vec < i32 > = data
1102+ . iter ( )
1103+ . flat_map ( |batch| {
1104+ let val_col: & Int32Array = batch. column ( 0 ) . as_primitive ( ) ;
1105+ ( 0 ..val_col. len ( ) ) . map ( |i| val_col. value ( i) ) . collect_vec ( )
1106+ } )
1107+ . collect ( ) ;
1108+ assert_eq ! ( all_values, ( 0 ..N ) . collect_vec( ) ) ;
1109+ }
1110+
1111+ #[ tokio:: test( flavor = "multi_thread" ) ]
1112+ async fn test_drain_chunk_panicked_task_is_join_failure_not_eof ( ) {
1113+ let ( tx, rx) = tokio:: sync:: mpsc:: channel :: < DeltaResult < Box < dyn EngineData > > > ( 1 ) ;
1114+ let handle = tokio:: spawn ( async {
1115+ panic ! ( "chunk task panicked" ) ;
1116+ } ) ;
1117+ drop ( tx) ;
1118+
1119+ let items: Vec < _ > = drain_chunk ( rx, handle) . collect ( ) . await ;
1120+ assert_eq ! (
1121+ items. len( ) ,
1122+ 1 ,
1123+ "panicked chunk must yield an error, not EOF"
1124+ ) ;
1125+ match & items[ 0 ] {
1126+ Err ( Error :: JoinFailure ( _) ) => { }
1127+ Err ( _) => panic ! ( "expected JoinFailure, got a different error" ) ,
1128+ Ok ( _) => panic ! ( "expected JoinFailure, got a batch" ) ,
1129+ }
1130+ }
1131+
8371132 // Helper function to create test data
8381133 fn create_test_data ( values : Vec < & str > ) -> DeltaResult < Box < dyn EngineData > > {
8391134 let schema = Arc :: new ( ArrowSchema :: new ( vec ! [ Field :: new(
0 commit comments