Skip to content

feat: add parallel json log reads for snapshot construction (#3246) - #3254

Open
andrei-ionescu wants to merge 6 commits into
delta-io:mainfrom
andrei-ionescu:ai.read_table_perf_improvement
Open

feat: add parallel json log reads for snapshot construction (#3246)#3254
andrei-ionescu wants to merge 6 commits into
delta-io:mainfrom
andrei-ionescu:ai.read_table_perf_improvement

Conversation

@andrei-ionescu

@andrei-ionescu andrei-ionescu commented Sep 3, 2026

Copy link
Copy Markdown

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 100.46 ms 40.093 ms 2.51x
2 99.403 ms 39.868 ms 2.49x
3 94.569 ms 39.750 ms 2.38x
4 93.588 ms 39.521 ms 2.36x
5 95.827 ms 40.228 ms 2.38x
6 93.520 ms 39.211 ms 2.39x
7 95.187 ms 39.709 ms 2.40x

Important

This is 2.41x faster (saved ~59% of time spent)!

This metrics were extracted from a similar output of cargo bench -p delta_kernel --bench metadata_bench -- create_snapshot:

create_snapshot/serial  
                        time:   [93.300 ms 93.588 ms 93.897 ms]
                        change: [-2.5782% -1.0375% +0.2986%] (p = 0.18 > 0.05)
                        No change in performance detected.
create_snapshot/parallel_chunks
                        time:   [39.272 ms 39.521 ms 39.777 ms]
                        change: [-1.3621% -0.5756% +0.0797%] (p = 0.13 > 0.05)
                        No change in performance detected.

What does it fix?

Closes #3246

How was this change tested?

  • test_read_json_files_parallel_ordering with 10k files, assert correct order
  • test_drain_chunk_panicked_task_is_join_failure_not_eof checking panic yields JoinFailure, not EOF
  • cargo +nightly fmt --check
  • cargo test
  • cargo nextest run -p delta_kernel_default_engine --lib --all-features
  • cargo bench -p delta_kernel --bench metadata_bench -- create_snapshot

@andrei-ionescu
andrei-ionescu force-pushed the ai.read_table_perf_improvement branch 7 times, most recently from cbf528c to 542eb2b Compare September 7, 2026 21:50
@andrei-ionescu

Copy link
Copy Markdown
Author

@chiinlquah, @scottsand-db, @dengsh12, @scovich, @DrakeLin, @rliao147 Could you have a look on this PR?

Greatly appreciated 🙏

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.12903% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.33%. Comparing base (9b25e81) to head (114236f).

Files with missing lines Patch % Lines
default-engine/src/json.rs 91.00% 39 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3254      +/-   ##
==========================================
+ Coverage   90.32%   90.33%   +0.01%     
==========================================
  Files         250      250              
  Lines       89038    89525     +487     
  Branches    89038    89525     +487     
==========================================
+ Hits        80422    80875     +453     
- Misses       5682     5713      +31     
- Partials     2934     2937       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Benchmark results: ✅ Pass

Summary: 🚀 0  ·  ✅ 8  ·  ☑️ 6  ·  🚧 1  ·  ❌ 0

Per-benchmark results (15 rows)
Test Change Base PR
clustered/readMetadataLatestPredicate/serial ✅ 1.03x faster 76.6±1.51ms 74.3±1.12ms
crcLatest/snapshotLatest ✅ 1.00x 7.6±0.09ms 7.6±0.15ms
crcMissing/snapshotLatest ✅ 1.01x faster 18.5±0.44ms 18.4±0.25ms
crcSlightlyStale/snapshotLatest ☑️ 1.01x slower 8.2±0.11ms 8.3±0.52ms
crcVeryStale/snapshotLatest ☑️ 1.01x slower 12.5±0.38ms 12.6±0.21ms
partitioned/readMetadataLatestPredicate/serial ☑️ 1.01x slower 42.5±1.44ms 43.1±1.96ms
v1Checkpoint/readMetadataLatest/serial ✅ 1.00x 9.6±0.06ms 9.6±0.29ms
v1Checkpoint/snapshotLatest ✅ 1.06x faster 650.1±180.34µs 610.7±30.77µs
v2Checkpoint/readMetadataLatest/parallel2 ✅ 1.00x 6.8±0.32ms 6.8±0.33ms
v2Checkpoint/readMetadataLatest/serial ✅ 1.01x faster 11.1±0.07ms 11.0±0.10ms
v2Checkpoint/snapshotLatest ☑️ 1.01x slower 598.4±15.35µs 603.2±24.35µs
wideSchemaJsonStats/readMetadataLatestPredicate/serial ☑️ 1.03x slower 58.5±0.66ms 60.2±1.16ms
wideSchemaJsonStats/snapshotLatest 🚧 1.04x slower 1854.3±31.41µs 1922.3±57.62µs
wideSchemaStructStats/readMetadataLatestPredicate/serial ✅ 1.01x faster 27.2±0.89ms 27.0±0.62ms
wideSchemaStructStats/snapshotLatest ☑️ 1.03x slower 1772.5±46.20µs 1829.6±31.07µs

Legend: 🚀 ≥1.15x faster  · ✅ faster or unchanged  · ☑️ ≤1.03x slower  · 🚧 1.03x-1.15x slower  · ❌ ≥1.15x slower
Commit: 114236f · Trigger: auto-push · Tags: base · Updated: 2026-09-10 14:29 PDT

@andrei-ionescu
andrei-ionescu force-pushed the ai.read_table_perf_improvement branch from 542eb2b to fe86e71 Compare September 8, 2026 16:21
@andrei-ionescu

Copy link
Copy Markdown
Author

I did some changes to fix the failing actions. Fast turnaround.

@chiinlquah, @scottsand-db, @dengsh12, @scovich, @DrakeLin, @rliao147 can you approve the actions/workflows run again?

Greatly appreciated 🙏

@andrei-ionescu
andrei-ionescu force-pushed the ai.read_table_perf_improvement branch 6 times, most recently from d8c5fc1 to 21c3656 Compare September 9, 2026 02:37
@andrei-ionescu

Copy link
Copy Markdown
Author

@chiinlquah, @scottsand-db, @dengsh12, @scovich, @DrakeLin, @rliao147 could you approve the execution of the Github actions? I did some changes trying to fix any failing actions.

Also, can someone be assigned for review if is not too much?

Thank you very much 🙏

@chiinlquah
chiinlquah requested review from chiinlquah, nicklan and scottsand-db and removed request for scottsand-db September 9, 2026 16:21

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review (draft - human review required)

Show review

This adds an opt-in parallel JSON log read path. The ordering design is sound: chunks are contiguous slices of the input file list, each chunk runs the existing read_json_files_impl (which preserves per-file order), and flat_map over the in-order (receiver, handle) pairs drains chunks strictly in order, so the global yield order matches the serial path. The protocol reviewer confirmed log-replay ordering and last-write-wins reconciliation are unaffected, and the panic-to-JoinFailure handling correctly avoids a silent truncated commit stream. Two issues in the concurrency plumbing should be fixed before merge.

Blocking issues

Blocker1 - default-engine/src/json.rs:183 (spawn) and :212 (drain)
Chunk tasks are spawned with tokio::spawn and referenced only by their JoinHandle. Dropping a JoinHandle detaches the task, it does not abort it. When the consumer cancels (CancellableStreamIterator drops the outer stream on a cancelled token) or stops early (for example try_collect short-circuits on the first Err), the receivers and handles are dropped but the spawned tasks keep issuing object-store GETs (up to buffered(per_chunk_buffer) in flight) until their next tx.send() observes the closed channel. This contradicts the cancellation contract documented on stream_future_to_cancellable_iter that dropping the stream releases in-flight buffered work.
Raised by: maintainer-codex-reviewer, maintainer-claude-reviewer
Suggested fix: hold the handles in a tokio::task::JoinSet, or wrap each in an abort-on-drop handle (for example tokio_util::task::AbortOnDropHandle), so dropping the stream aborts outstanding tasks. Still await each handle on the normal path to preserve JoinFailure detection. Add a test that drops the iterator mid-stream and asserts the tasks stop.

Blocker2 - default-engine/src/json.rs:167,172
channel_cap = per_chunk_buffer.saturating_mul(4) and each chunk pipeline also keeps about per_chunk_buffer reads in flight, so aggregate peak is roughly 4 * buffer_size + buffer_size = about 5x buffer_size batches. Because chunks run concurrently but are consumed strictly in chunk order, every not-yet-drained chunk fills its channel while the consumer is still on chunk 0, so this is steady state rather than a rare worst case. It breaks the memory bound documented on with_buffer_size.
Raised by: maintainer-claude-reviewer
Suggested fix: drop the x4 multiplier (channel_cap = per_chunk_buffer) or use a small fixed cap, and update the with_buffer_size doc to describe behavior under parallelism.

Non-blocking notes

Nit1 - default-engine/src/lib.rs:298
The builder doc says Some(n) "splits the file list into n chunks", but the actual count can be lower: num_chunks is clamped to files.len() and chunk_size uses div_ceil, so 8 files with n=5 yields 4 chunks, and fewer files than n yields at most files.len() chunks.
Raised by: docs-reviewer
Suggested fix: describe n as an upper bound, for example "splits the file list into up to n chunks (fewer when there are fewer files than n)".

Nit2 - default-engine/src/json.rs:183
The handler holds task_executor to inject the async runtime, but the parallel path calls tokio::spawn directly, coupling it to the ambient Tokio runtime. Under the default TokioBackgroundExecutor (a current-thread runtime) chunk tasks multiplex on one thread and the speedup does not materialize, which is why the benchmark had to switch to TokioMultiThreadExecutor. The public with_parallel_chunks knob therefore has no effect on the default engine.
Raised by: architecture-reviewer, maintainer-codex-reviewer, maintainer-claude-reviewer
Suggested fix: spawn through the executor abstraction (extending TaskExecutor::spawn to return a joinable handle, which is what powers JoinFailure detection), or document that with_parallel_chunks requires a multi-thread executor.

Nit3 - default-engine/src/json.rs:197
Negative and concurrency paths are only exercised with a single file, which is a single chunk. An error originating in a non-first chunk, the consumer-drop/backpressure path (tx.send().await.is_err()), and non-divisible chunk boundaries are untested; value-asserting tests only use exact multiples.
Raised by: test-coverage-reviewer
Suggested fix: add a test where a later chunk errors after earlier chunks produced data (assert prior data arrives before the error), a test that drops the iterator mid-stream, and ragged chunk cases such as 7 files with 3 chunks.

Summary

The parallelization preserves the ordering guarantees log replay depends on and hardens against silent commit truncation, so the feature is functionally correct on the happy path. Before merge, tie spawned task lifetime to the stream so cancellation and early drop stop background I/O, and rein in the channel sizing so the parallel path respects the documented buffer_size memory bound. The executor bypass and test gaps are worth addressing but are not blocking.


Automated review - workflow run

Comment thread default-engine/src/json.rs Outdated
Comment thread default-engine/src/json.rs Outdated
Comment thread default-engine/src/lib.rs Outdated
let predicate = predicate.clone();

let handle = tokio::spawn(async move {
let result = read_json_files_impl(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit2 The handler holds task_executor to inject the async runtime, but the parallel path calls tokio::spawn directly, coupling it to the ambient Tokio runtime. Under the default TokioBackgroundExecutor (current-thread runtime) chunk tasks multiplex on one thread and the speedup does not materialize, which is why the benchmark switched to TokioMultiThreadExecutor; the public with_parallel_chunks knob has no effect on the default engine. Raised by: architecture-reviewer, maintainer-codex-reviewer, maintainer-claude-reviewer. Suggested fix: spawn through the executor abstraction (extending TaskExecutor::spawn to return a joinable handle), or document that with_parallel_chunks requires a multi-thread executor.

Ok(batch_stream) => {
let mut batch_stream = std::pin::pin!(batch_stream);
while let Some(batch) = batch_stream.next().await {
if tx.send(batch).await.is_err() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit3 Negative and concurrency paths are only exercised with a single file (single chunk). An error originating in a non-first chunk, the consumer-drop/backpressure path (tx.send().await.is_err()), and non-divisible chunk boundaries are untested; value-asserting tests use only exact multiples. Raised by: test-coverage-reviewer. Suggested fix: add a test where a later chunk errors after earlier chunks produced data, a test that drops the iterator mid-stream, and ragged chunk cases such as 7 files with 3 chunks.

@andrei-ionescu
andrei-ionescu force-pushed the ai.read_table_perf_improvement branch 2 times, most recently from 8dbd959 to b52c222 Compare September 10, 2026 14:34
@andrei-ionescu

Copy link
Copy Markdown
Author

I tried to address the feedback from AI Review. @chiinlquah, @scottsand-db, @dengsh12, @scovich, @DrakeLin, @rliao147 could you let the actions run again?

@andrei-ionescu
andrei-ionescu force-pushed the ai.read_table_perf_improvement branch from b52c222 to 114236f Compare September 10, 2026 20:18
@andrei-ionescu

Copy link
Copy Markdown
Author

Once more please @chiinlquah, @scottsand-db, @dengsh12, @scovich, @DrakeLin, @rliao147?

I feel bad to ask you so many times for this PR. 🙏

@chiinlquah

Copy link
Copy Markdown
Collaborator

@andrei-ionescu No worries : ) Thanks for the contribution!! Will try to get to review the PR soon as well

@andrei-ionescu

Copy link
Copy Markdown
Author

Thank you @chiinlquah 😄

…#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.42x   |
| 2   | 95.9 ms | 39.7 ms  | 2.42x   |
| 3   | 96.5 ms | 39.6 ms  | 2.44x   |
| 4   | 94.6 ms | 39.1 ms  | 2.42x   |

This is **2.42x 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`
@andrei-ionescu
andrei-ionescu force-pushed the ai.read_table_perf_improvement branch from 114236f to 9245f77 Compare September 11, 2026 13:52
@andrei-ionescu

Copy link
Copy Markdown
Author

Rebased and ready for what's next... @chiinlquah, @scottsand-db, @dengsh12, @scovich, @DrakeLin, @rliao147?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Increase performance of opening the table

2 participants