Skip to content

1537: feat: move shuffle writer disk I/O off tokio worker threads - #10

Open
martin-augment wants to merge 3 commits into
mainfrom
pr-1537-2026-04-02-05-38-41
Open

1537: feat: move shuffle writer disk I/O off tokio worker threads#10
martin-augment wants to merge 3 commits into
mainfrom
pr-1537-2026-04-02-05-38-41

Conversation

@martin-augment

Copy link
Copy Markdown
Owner

1537: To review by AI

hcrosse and others added 3 commits April 1, 2026 21:48
Synchronous file I/O inside async shuffle writer functions blocks tokio
workers, hurting throughput and responsiveness under concurrent loads.
This wraps all disk writes in spawn_blocking tasks fed through bounded
mpsc channels, so workers stay free while Arrow StreamWriter and
BatchPartitioner run on the blocking thread pool.

Both the no-repartition and hash repartition paths use the same
pattern: the async side reads the input stream and sends batches
through a channel with capacity 2, while the blocking side writes
them to disk. The sender is always dropped and the blocking handle
is always awaited before returning, even on error.

Also fixes write_time double-counting on the no-repartition path and
adds write-failure regression tests for both code paths.
Adds a --concurrency flag that runs multiple input partitions in
parallel using tokio::spawn with a Semaphore for backpressure.
Defaults to 1, preserving the original sequential behavior.

This makes it possible to measure how shuffle writes perform under
realistic concurrent workloads, where the async I/O changes have
the most impact.
@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown

Walkthrough

This pull request adds AI agent guidance rules prohibiting links to GitHub issues/pull requests in code reviews and review of agent configuration files. It refactors ballista shuffle and file I/O operations from synchronous to async patterns using tokio's fs and spawn_blocking APIs with bounded MPSC channels for producer/consumer coordination. A new --concurrency CLI parameter is added to shuffle benchmarks, enabling optional concurrent partition execution with semaphore-based parallelism control. Tokio dependency features are updated to include fs and sync flags across affected Cargo.toml files. Error propagation tests are added for disk write failures.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr-1537-2026-04-02-05-38-41

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Code Review

Summary

This PR correctly addresses blocking I/O on tokio worker threads by offloading file writes to spawn_blocking tasks and using bounded mpsc channels to bridge the async and blocking worlds. The core approach is sound. Below are specific observations.

Issues

  1. Double timer removal changes metric semantics (shuffle_writer.rs, None branch)

The outer write_time timer that wrapped both create_dir_all and the write_stream_to_disk call was removed. Now write_time is only incremented inside write_stream_to_disk per-batch. The original code double-counted write time (the outer timer ran concurrently with the inner per-batch timers). The fix silently resolves that double-count without a comment - worth noting in a code comment that the inner metric is now the canonical measure.

  1. spawn_blocking task error is silently dropped when stream also errors

In both write_stream_to_disk and the hash partition path in shuffle_writer.rs, when stream_err.is_some() the write task result is discarded. If the write task has an independent failure (e.g. disk full detected during writer.finish() after the stream errored), that error is silently swallowed. Consider at minimum logging it with warn!().

  1. Channel buffer of 2 may create unnecessary contention

A buffer of 2 means the async reader blocks after 2 un-consumed batches. For small batches with slow I/O this forces frequent context switches. A value of 4-8 would reduce round-trips without meaningfully increasing memory pressure. Alternatively, expose it as a configurable constant.

  1. Benchmark concurrency logic duplicated

The semaphore-based concurrent execution block is copy-pasted verbatim between benchmark_hash_shuffle and benchmark_sort_shuffle. Extracting a generic helper would make future changes easier to apply consistently.

  1. num_bytes still counts in-memory size, not on-disk bytes

num_bytes += batch.get_array_memory_size() measures uncompressed in-memory size. After LZ4 compression the on-disk size is smaller. This was a pre-existing issue, but this refactor is a good opportunity to fix it - use fs::metadata(&path)?.len() after writing (as already done in the hash-partition finalizer) or track the count inside the blocking task.

Minor Observations

  • path.to_str().unwrap() in shuffle_writer.rs None branch is a pre-existing panic risk on non-UTF-8 paths; not introduced here but worth a follow-up.
  • Benchmark drop(permit) placement: drop(permit) is inside the spawned task after collect_stream, so the semaphore slot is released only once the full partition is consumed. This is the correct behaviour; just noting it is intentional.
  • tokio::fs feature addition is correctly added to ballista/core/Cargo.toml to support tokio::fs::create_dir_all.

Test Coverage

The two new tests (test_no_repart_write_failure_propagates and test_hash_repart_write_failure_propagates) cover the primary error paths introduced by this refactor. Good addition. It would also be valuable to add a test that verifies metrics (input_rows, output_rows) are correctly recorded after the async/blocking split, since those counters now cross a thread boundary via cloned handles.

Overall

The design is correct and the change achieves its goal. Addressing the silent error discard (issue 2) and the in-memory vs on-disk byte count (issue 5) before merging would be worthwhile. The rest are low-priority polish items.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request refactors the shuffle writer and disk writing utilities to offload synchronous file I/O to blocking tasks using tokio::task::spawn_blocking and mpsc channels, preventing the async executor from being blocked. It also adds a concurrency parameter to the shuffle benchmarks and introduces unit tests for error propagation during write failures. Feedback suggests increasing the mpsc channel capacity from 2 to 16 in both shuffle_writer.rs and utils.rs to improve throughput and pipelining by reducing context switching.

path,
});
let schema = stream.schema();
let (tx, mut rx) = tokio::sync::mpsc::channel::<RecordBatch>(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The channel capacity of 2 is quite low for a shuffle operation. While it provides backpressure, such a small buffer might lead to excessive context switching between the async stream reader and the blocking writer task, potentially reducing throughput. Consider increasing this to a larger value like 16 or 32 to allow for better pipelining of record batches.

Suggested change
let (tx, mut rx) = tokio::sync::mpsc::channel::<RecordBatch>(2);
let (tx, mut rx) = tokio::sync::mpsc::channel::<RecordBatch>(16);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

value:good-to-have; category:bug; feedback: The Gemini AI reviewer is correct! The channel used for passing back the data from the blocking task may contain at most two items. If the receiver is slow for any reason then the sender will block and the thread context will switch. A channel with more slots will allow to reduce the thread context switching by using more memory.

let path_owned = path.to_owned();
let write_metric = disk_write_metric.clone();

let (tx, mut rx) = tokio::sync::mpsc::channel::<RecordBatch>(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The channel capacity of 2 is quite low for high-throughput disk I/O. A larger buffer (e.g., 16 or 32) would allow the async stream to continue reading and processing the next batches while the blocking task is busy with synchronous file writes, improving overall performance through better pipelining.

Suggested change
let (tx, mut rx) = tokio::sync::mpsc::channel::<RecordBatch>(2);
let (tx, mut rx) = tokio::sync::mpsc::channel::<RecordBatch>(16);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

value:good-to-have; category:bug; feedback: The Gemini AI reviewer is correct! The channel used for passing back the data from the blocking task may contain at most two items. If the receiver is slow for any reason then the sender will block and the thread context will switch. A channel with more slots will allow to reduce the thread context switching by using more memory.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
benchmarks/src/bin/shuffle_bench.rs (1)

293-326: Code duplication with hash shuffle benchmark.

The concurrent execution logic (lines 293-326) is nearly identical to benchmark_hash_shuffle (lines 199-232). For a benchmark utility this is acceptable, but consider extracting a helper if this pattern is reused further.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@benchmarks/src/bin/shuffle_bench.rs` around lines 293 - 326, Extract the
repeated concurrent-partition execution block used in both the current shuffle
benchmark and benchmark_hash_shuffle into a shared helper function (e.g.,
run_concurrent_partitions) that takes the shuffle writer (shuffle_writer), task
context (task_ctx), input_partition_count, and concurrency and returns
Result<total_files, Box<dyn std::error::Error>>; inside the helper reuse the
same logic: create Arc<tokio::sync::Semaphore>, spawn tasks that acquire_owned a
permit, call writer.execute(partition, ctx) and collect via
utils::collect_stream, count rows via batches.first().map_or(0, |b|
b.num_rows()), drop the permit, gather results from handles and return the first
error if any, otherwise the total_files; replace the duplicated blocks in both
functions with calls to this helper to remove duplication while keeping existing
symbols (shuffle_writer, task_ctx, utils::collect_stream, execute,
acquire_owned).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@benchmarks/src/bin/shuffle_bench.rs`:
- Around line 293-326: Extract the repeated concurrent-partition execution block
used in both the current shuffle benchmark and benchmark_hash_shuffle into a
shared helper function (e.g., run_concurrent_partitions) that takes the shuffle
writer (shuffle_writer), task context (task_ctx), input_partition_count, and
concurrency and returns Result<total_files, Box<dyn std::error::Error>>; inside
the helper reuse the same logic: create Arc<tokio::sync::Semaphore>, spawn tasks
that acquire_owned a permit, call writer.execute(partition, ctx) and collect via
utils::collect_stream, count rows via batches.first().map_or(0, |b|
b.num_rows()), drop the permit, gather results from handles and return the first
error if any, otherwise the total_files; replace the duplicated blocks in both
functions with calls to this helper to remove duplication while keeping existing
symbols (shuffle_writer, task_ctx, utils::collect_stream, execute,
acquire_owned).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b5806916-4246-4aff-9ab6-eb01b430bdea

📥 Commits

Reviewing files that changed from the base of the PR and between 5d6ddd8 and 472f2f3.

📒 Files selected for processing (8)
  • .cursor/rules.md
  • AGENTS.md
  • CLAUDE.md
  • ballista/core/Cargo.toml
  • ballista/core/src/execution_plans/shuffle_writer.rs
  • ballista/core/src/utils.rs
  • benchmarks/Cargo.toml
  • benchmarks/src/bin/shuffle_bench.rs

@martin-augment

Copy link
Copy Markdown
Owner Author

293-326: Code duplication with hash shuffle benchmark.

The concurrent execution logic (lines 293-326) is nearly identical to benchmark_hash_shuffle (lines 199-232). For a benchmark utility this is acceptable, but consider extracting a helper if this pattern is reused further.

value:good-to-have; category:bug; feedback: The CodeRabbit AI reviewer is correct! The logic for setup is duplicated for the two benchmark tests. It would be good to extract it to a helper method and reuse it.

@martin-augment

Copy link
Copy Markdown
Owner Author

7. Benchmark concurrency logic duplicated

The semaphore-based concurrent execution block is copy-pasted verbatim between benchmark_hash_shuffle and benchmark_sort_shuffle. Extracting a generic helper would make future changes easier to apply consistently.

value:good-to-have; category:bug; feedback: The Claude AI reviewer is correct! The logic for setup is duplicated for the two benchmark tests. It would be good to extract it to a helper method and reuse it.

@martin-augment

Copy link
Copy Markdown
Owner Author

3. spawn_blocking task error is silently dropped when stream also errors

In both write_stream_to_disk and the hash partition path in shuffle_writer.rs, when stream_err.is_some() the write task result is discarded. If the write task has an independent failure (e.g. disk full detected during writer.finish() after the stream errored), that error is silently swallowed. Consider at minimum logging it with warn!().

value:useful; category:bug; feedback: The Claude AI reviewer is correct! Two unrelated operations are executed and both could produce their own errors. One of them is threaten with priority and the other one is just silently dropped. It would be better to at least log it for full context.

@martin-augment

Copy link
Copy Markdown
Owner Author

5. Channel buffer of 2 may create unnecessary contention

A buffer of 2 means the async reader blocks after 2 un-consumed batches. For small batches with slow I/O this forces frequent context switches. A value of 4-8 would reduce round-trips without meaningfully increasing memory pressure. Alternatively, expose it as a configurable constant.

value:good-to-have; category:bug; feedback: The Claude AI reviewer is correct! The channel used for passing back the data from the blocking task may contain at most two items. If the receiver is slow for any reason then the sender will block and the thread context will switch. A channel with more slots will allow to reduce the thread context switching by using more memory.

@augmentcode

augmentcode Bot commented Apr 6, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR moves shuffle-writer disk I/O off tokio worker threads by routing record batches through bounded channels to blocking writer tasks.

Changes:

  • Use tokio::fs::create_dir_all for async directory creation in the non-repartitioned shuffle-writer path.
  • Refactor hash-partition shuffle writing to forward input batches via tokio::sync::mpsc to a spawn_blocking task that performs partitioning and synchronous Arrow IPC writes.
  • Refactor utils::write_stream_to_disk to forward batches via a bounded channel to a blocking task that owns the IPC StreamWriter and file handle.
  • Add tests asserting write failures in both non-repartitioned and hash-repartitioned shuffle paths propagate back to the caller.
  • Extend the shuffle benchmark with a --concurrency option to run multiple input partitions concurrently, and enable required tokio features in the benchmark crate.
  • Enable tokio fs feature for ballista-core to support the new async filesystem calls.

Technical Notes: Both shuffle paths now use small bounded channels (size 2) to provide backpressure while keeping blocking filesystem and IPC writes off the async runtime threads.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode 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.

Review completed. 1 suggestion posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

let timer = disk_write_metric.timer();
writer.write(&batch)?;
timer.done();
if let Some(e) = stream_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.

ballista/core/src/utils.rs:225: When stream_err is Some(_), this returns the stream error without checking write_result, which can mask a concurrent disk-writer failure from the spawn_blocking task. If both can happen, it may be worth deciding which error should win (or logging/combining them) to avoid losing the underlying I/O failure.

Severity: medium

Other Locations
  • ballista/core/src/execution_plans/shuffle_writer.rs:359

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

value:useful; category:bug; feedback: The Augment AI reviewer is correct! Two unrelated operations are executed and both could produce their own errors. One of them is threaten with priority and the other one is just silently dropped. It would be better to at least log it for full context.

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.

3 participants