Skip to content

Commit cbf528c

Browse files
feat: add parallel json log reads for snapshot construction (#3246)
## What changes are proposed in this pull request? Opt-in parallel JSON log reads for snapshot construction. `None` (default) keeps the existing serial path. `Some(n)` splits the ordered file list into `n` chunks, parses them concurrently as tokio tasks, and concatenates results in proper order. Here are some performance results: | Run | Serial | Parallel | Speedup | |-----|---------|----------|---------| | 1 | 96.6 ms | 40.0 ms | 2.42× | | 2 | 95.9 ms | 39.7 ms | 2.42× | | 3 | 96.5 ms | 39.6 ms | 2.44× | | 4 | 94.6 ms | 39.1 ms | 2.42× | This is **2.42× faster** (saved ~59% of time spent)! ## How was this change tested? - `test_read_json_files_parallel_ordering` with 10k files, assert row order - `test_drain_chunk_panicked_task_is_join_failure_not_eof` checking panic yields `JoinFailure`, not EOF - `cargo nextest run -p delta_kernel_default_engine --lib --all-features` - `cargo bench -p delta_kernel --bench metadata_bench -- create_snapshot`
1 parent 6d65a8a commit cbf528c

3 files changed

Lines changed: 346 additions & 26 deletions

File tree

default-engine/src/json.rs

Lines changed: 305 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ use delta_kernel::{
2424
FileDataReadResultIterator, FileMeta, FileSize, JsonHandler, PredicateRef,
2525
};
2626
use futures::stream::{self, BoxStream};
27-
use futures::{ready, StreamExt, TryStreamExt};
27+
use futures::{ready, FutureExt, StreamExt, TryStreamExt};
28+
use tokio::sync::mpsc;
2829
use url::Url;
2930

3031
use 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

4751
impl<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

Comments
 (0)