Parallelize segment staging copy to deep store - #19082
Conversation
Batch Spark/Hadoop generation jobs spent wall-clock time on a single-threaded PinotFS move of hundreds of segment tars from staging to deep store. Parallel copy with bounded concurrency cuts job latency without changing push semantics.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #19082 +/- ##
=========================================
Coverage 65.47% 65.47%
+ Complexity 1421 1415 -6
=========================================
Files 3426 3426
Lines 217315 217384 +69
Branches 34509 34525 +16
=========================================
+ Hits 142283 142333 +50
- Misses 63513 63527 +14
- Partials 11519 11524 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Ready for review — all required CI checks are green on this PR. Issue: #7510 Could the following folks take a look when convenient? Formal GitHub "Request review" is unavailable from a fork contributor account on Thank you! |
There was a problem hiding this comment.
Pull request overview
This PR removes the remaining single-threaded bottleneck in batch ingestion segment staging by parallelizing the staging → deep-store “move” step, and wiring Spark-3 and Hadoop runners to use a configurable bounded parallelism.
Changes:
- Extend
SegmentGenerationJobUtils.moveFiles(...)with a bounded thread-pool implementation and astagingCopyParallelismextra-config parser (default + max cap). - Update Spark-3 and Hadoop segment generation runners to pass the resolved staging copy parallelism to
moveFiles(...). - Add unit tests validating layout preservation across parallelism levels, overwrite behavior, and failure propagation.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-spark-3/.../SparkSegmentGenerationJobRunner.java |
Uses stagingCopyParallelism from extra configs and invokes parallel moveFiles(...) for staging → output move. |
pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-hadoop/.../HadoopSegmentGenerationJobRunner.java |
Uses stagingCopyParallelism from extra configs and invokes parallel moveFiles(...) for staging → output move. |
pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-common/.../SegmentGenerationJobUtilsTest.java |
Adds unit coverage for parallel move equivalence, overwrite semantics, and failure aggregation. |
pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-common/.../SegmentGenerationJobUtils.java |
Implements bounded parallel move, adds config parsing, and refactors file listing/move into helpers. |
| if (!overwrite && fs.exists(destFileUri)) { | ||
| LOGGER.warn("Can't overwrite existing output segment tar file: {}", destFileUri); | ||
| } else { | ||
| fs.move(sourceFileUri, destFileUri, true); | ||
| } |
| private static List<URI> listSourceFiles(PinotFS fs, URI sourceDir) | ||
| throws IOException, URISyntaxException { | ||
| List<URI> sourceFileUris = new ArrayList<>(); | ||
| for (String sourcePath : fs.listFiles(sourceDir, true)) { | ||
| URI sourceFileUri = SegmentGenerationUtils.getFileURI(sourcePath, sourceDir); | ||
| String sourceFilename = SegmentGenerationUtils.getFileName(sourceFileUri); | ||
| URI destFileUri = | ||
| SegmentGenerationUtils.getRelativeOutputPath(sourceDir, sourceFileUri, destDir).resolve(sourceFilename); | ||
|
|
||
| if (!overwrite && fs.exists(destFileUri)) { | ||
| LOGGER.warn("Can't overwrite existing output segment tar file: {}", destFileUri); | ||
| } else { | ||
| fs.move(sourceFileUri, destFileUri, true); | ||
| if (fs.isDirectory(sourceFileUri)) { | ||
| continue; | ||
| } | ||
| sourceFileUris.add(sourceFileUri); | ||
| } | ||
| return sourceFileUris; |
| int effectiveParallelism = Math.max(1, Math.min(parallelism, sourceFileUris.size())); | ||
| LOGGER.info("Moving {} files from [{}] to [{}] with parallelism {}", sourceFileUris.size(), sourceDir, destDir, | ||
| effectiveParallelism); |
| } catch (InterruptedException e) { | ||
| interrupted = true; | ||
| future.cancel(true); | ||
| if (firstFailure == null) { | ||
| firstFailure = new IOException("Interrupted while moving files from " + sourceDir + " to " + destDir, e); | ||
| } else { | ||
| firstFailure.addSuppressed(e); | ||
| } | ||
| } catch (Exception e) { |
Why
Batch Spark/Hadoop segment generation jobs were spending a large share of wall-clock time on a single-threaded
PinotFScopy/move of segment tars from the staging directory to deep store (S3/HDFS/…). For jobs that produce hundreds of segments, that serial hop dominated the SLA even after segment build and controller push were already parallelized.This is the remaining bottleneck called out in #7510 (staging→deep-store copy), not tar push (which already supports
pushParallelism).Impact
moveis a remote copy+delete.How
SegmentGenerationJobUtils.moveFileswith a bounded thread-pool parallel implementation and an optional parallelism argument.Test plan
SegmentGenerationJobUtilsTestcovering parallel vs serial layout equivalence, overwrite true/false, and failure aggregation (LocalPinotFS)../mvnw -pl pinot-plugins/pinot-batch-ingestion/pinot-batch-ingestion-common -am -Dtest=SegmentGenerationJobUtilsTest -Dsurefire.failIfNoSpecifiedTests=false testRelated
closes: #7510
Reviewers
Suggested: xiangfu0, kkrugler (issue discussion / batch ingestion)
Was generative AI tooling used to co-author this PR?
Generated-by: Grok Build (xAI)