Repository navigation
[WriteFiles] Support direct writes of large streaming bundles to temp files (#40533) - #40543
tejasiyer-dev wants to merge 11 commits into
Conversation
|
Checks are failing. Will not request review until checks are succeeding. If you'd like to override that behavior, comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #40543 +/- ##
============================================
+ Coverage 56.16% 64.98% +8.81%
- Complexity 2288 13977 +11689
============================================
Files 1124 1729 +605
Lines 178336 108641 -69695
Branches 1489 11332 +9843
============================================
- Hits 100170 70595 -29575
+ Misses 75645 33838 -41807
- Partials 2521 4208 +1687
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:
|
|
Assigning reviewers: R: @ahmedabu98 for label java. This pull request likely touches a core component ("core" label). Please review with scrutiny. Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
|
Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment |
|
Can we move sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/BundleLifter.java into sdks/java/io/common and reuse it? |
|
cc: @tomstepp |
|
Agreed, it would be nice to move and reuse BundleLifter |
|
+1 to that, but please make sure it doesn't break streaming update compatibility for the iceberg sink |
I think moving BundleLifter to sdks/java/io/common would create a circular dependency. sdks/java/io/common already depends on sdks/java/core and WriteFiles is in sdks/java/core, so to use BundleLifter from io/common, core would also have to depend on io/common. Would moving BundleLifter to core be better? Iceberg already depends on Core so both Iceberg and WriteFiles could share it with no new dependencies |
|
@ahmedabu98 PTAL. Also, seems like |
I think that is okay. We could move it to sdks/java/core/src/main/java/org/apache/beam/sdk/util and mark it with @org.apache.beam.sdk.annotations.Internal |
ahmedabu98
left a comment
There was a problem hiding this comment.
Generally LGTM, just a nit about modularization and small question about the experiment flag
| TupleTagList.of(ImmutableList.of(smallBundlesTag, BAD_RECORD_TAG)))); | ||
| addErrorCollection(routed); | ||
|
|
||
| PCollection<UserT> smallBundles = routed.get(smallBundlesTag).setCoder(input.getCoder()); |
There was a problem hiding this comment.
Is there a way to reuse the existing pipeline graph (built in WriteFilesResult's expand) and pass smallBundles through to it? Instead of attempting to recreate the same steps here?
There was a problem hiding this comment.
I see you did a good amount for that using writeAutoShardedRecordsToTempFiles(...). Wondering if other parts can be similarly modularized
There was a problem hiding this comment.
Partly. The small bundles now reuse the existing write steps, and both paths share the grouping steps and batch-size defaults through new helpers.
The small bundles can't go through the entire existing graph, though. If they did, the direct-write files and the small-bundle files would each go through a separate finalize step, and their file names would collide. Instead, both sets of temp files are merged before finalize, so a single finalize step assigns unique shard numbers to every file in the window.
| * The `disableCounterMetrics`, `disableStringSetMetrics` and `disableBoundedTrieMetrics` experiments are now honored by the Python SDK, as they already were in Java (Python) ([#38746](https://github.com/apache/beam/issues/38746)). | ||
| * (Go) Pipeline-level `--resource_hints` are now forwarded to expansion services, so they apply to cross-language transforms as they do in the Java and Python SDKs. The `max_active_bundles_per_worker` hint name is now also accepted by `--resource_hints` ([#23893](https://github.com/apache/beam/issues/23893)). | ||
| * ReadFromBigQuery now supports Lakehouse runtime catalog (BigLake metastore) tables with `method=DIRECT_READ`, using 4-part `project.catalog.namespace.table` identifiers. Previously `project:catalog.namespace.table` was silently mis-parsed and tables that report no `numBytes` failed to split (Python) ([#39597](https://github.com/apache/beam/issues/39597)). | ||
| * (Java) `TextIO`, `FileIO`, and `WriteFiles`: streaming writes can skip the shuffle for large bundles by writing them directly to temp files (`withDirectWriteByteThreshold` or experiment `streaming_file_direct_write_threshold_bytes`). Off by default ([#40533](https://github.com/apache/beam/issues/40533)). |
There was a problem hiding this comment.
Not seeing thestreaming_file_direct_write_threshold_bytes flag in the code here. is it in the Dataflow service?
There was a problem hiding this comment.
No, it's not in the Dataflow service. I removed that experiment so direct write is configured only through withDirectWriteByteThreshold (matching Iceberg), but forgot to update CHANGES.md. Fixed now, thanks!
| for (BufferedRecord<UserT> record : buffer) { | ||
| c.output(smallBundlesTag, record.value, record.timestamp, record.window); | ||
| } | ||
| for (BufferedRecord<UserT> record : spilled) { | ||
| c.output(smallBundlesTag, record.value, record.timestamp, record.window); |
There was a problem hiding this comment.
seems like this will reorder outputs. Can we preserve the order?
There was a problem hiding this comment.
The two loops are no longer back to back and each one now sits inside its own branch, so only one runs per bundle.
Direct mode: the buffer is flushed when we switch, so only spilled is emitted.
Grouped mode: nothing is written directly, so only buffer is emitted.
Each list is emitted in arrival order, so nothing gets reordered.
| public void finishBundle(FinishBundleContext c) throws Exception { | ||
| if (directMode) { | ||
| directBundles.inc(); | ||
| } else if (!buffer.isEmpty()) { |
There was a problem hiding this comment.
since directMode, !buffer.isEmpty() can both be true, do we need the else here?
There was a problem hiding this comment.
Yes, the else is needed, because directMode and !buffer.isEmpty() can't both be true: the buffer is flushed and cleared the moment we switch to direct mode.
@chamikaramj @arunpandianp @ahmedabu98, I've moved BundleLifter to sdk/util with @internal in a separate PR: #40978. Making WriteFiles reuse BundleLifter will be a follow-up once both are merged. Could you take another look and approve the workflows so CI can run on the latest changes? Thanks! |
| return; | ||
| } | ||
| DestinationT destination = maybeDestination.destination; | ||
| WriterKey<DestinationT> key = new WriterKey<>(record.window, record.pane, destination); |
There was a problem hiding this comment.
since the goal is to write large files when possible, should we be grouping records by WriterKey and see if the grouped records meet the threshold, instead of applying the threshold to the whole bundle across all windows and destinations?
…reached. Evaluate the direct-write threshold per WriterKey instead of per bundle.
|
#40978 (moving BundleLifter from Iceberg to sdk/util) has merged, so I've updated this PR to use it. Per-WriterKey threshold: BundleLifter now takes an optional key function, and WriteFiles uses it to check the threshold per window/pane/destination (WriterKey). Without a key function, BundleLifter still checks the threshold against the whole bundle. Iceberg can also check the threshold per table/partition instead of per bundle by passing a key function but I've left its threshold behavior unchanged for now. Maybe it can be incorporated in a follow up PR. Streaming: Once a key reaches the threshold, its buffered elements are flushed to the large output, and later elements for that key are output directly without sizing or buffering. @arunpandianp @ahmedabu98 @scwhittle PTAL, and could someone also approve the workflows and merge afterwards? Thanks! |
| Long directWriteThreshold = getDirectWriteByteThreshold(); | ||
| if (directWriteThreshold != null | ||
| && getSideInputs().isEmpty() | ||
| && input.isBounded() == IsBounded.UNBOUNDED) { |
There was a problem hiding this comment.
We should disable direct write when the window is merging like in https://github.com/tejasiyer-dev/beam/blob/b1fe445521135fe623ef2d69ae1dd11361dbf1e4/sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java#L573
when directWriteThreshold is present and if we are not enabling it, should we throw an error or fallback? When falling back adding a log saying direct write is disabled with a reason would be nice to have.
There was a problem hiding this comment.
Good catch! I disabled direct write for merging windows using the same needsMerge() check.
When directWriteThreshold is set but direct write can't be used, I think we should fall back to the default grouped path and log a warning with the reason. The pipeline still writes all the same records to the same files, it just doesn't get the optimization.
…with log warning if directWriteThreshold is set but direct write can't be used. Also evict BundleLifter keys in one pass when over the memory cap.
Fixes #40533
Summary
In streaming pipelines,
WriteFilesshuffles every element either viaGroupByKey(when a shard count or runner-determined sharding is used) orGroupIntoBatches.withShardedKey()(when auto-sharded) before writing temporary files. When upstream bundles are already large enough to produce well-sized temporary files on their own, shuffling all elements incurs unnecessary network and state overhead.This PR adds an optional direct-write byte threshold that allows sufficiently large streaming bundles to write directly to temporary files, while routing smaller bundles through the existing grouped write path. Similar to
IcebergIO direct write (#36720).
Changes
WriteFiles:withDirectWriteByteThreshold(long)to configure the byte threshold for direct bundle writes on unbounded inputs.WriteShardedBundlesWithDirectWrite, which buffers incoming elements per bundle up tothresholdBytesusing the input coder's byte-size estimation:>= thresholdBytes): Written directly to temporary files viaDirectWriteLargeBundlesFnwithout shuffling. If a bundle touches more thanmaxNumWritersPerBundledistinct(window, destination)writers, excess records spill over to the grouped path.< thresholdBytes) and spillover records: Routed throughWriteShardedBundlesToTempFiles(when a shard count is configured) orGroupIntoBatches.withShardedKey()(when unsharded / auto-sharded), then merged with the direct-writeFileResults beforeFinalizeTempFileBundles.TextIO&FileIO:.withDirectWriteByteThreshold(long)pass-through methods toTextIO.Write,TextIO.TypedWrite, andFileIO.Write.DataflowRunner:StreamingShardedWriteFactoryto preservegetDirectWriteByteThreshold()when constructing the replacementWriteFilestransform on Dataflow Runner v1.WriteFiles:directWriteBundlesandgroupedWriteBundles.Testing
WriteFilesTest:testDirectWriteAllBundlesLarge(large bundles bypass shuffle and write directly)testDirectWriteAllBundlesSmall(small bundles fall back to the grouped write path)testDirectWriteUnshardedMixedBundles(exercises both direct-write and auto-sharded fallback paths in the same pipeline)testDirectWriteNonDefaultTriggerLargeBundles&testDirectWriteNonDefaultTriggerSmallBundles(verifies non-default user triggers across both paths)testDirectWriteDisabledWithSideInputs(verifies fallback when dynamic destinations use side inputs)DataflowRunnerTest:testStreamingWriteWithDirectWriteByteThresholdtestStreamingWriteDirectWriteThresholdDefaultNullTextIOWriteTestandFileIOTest.Benchmark
30-minute Dataflow streaming run (
5x n1-standard-4, ~80 MB/s input, 1-minuteFixedWindows,TextIO.write().withWindowedWrites()):direct-write-before):2026-10-12_12_51_21-9229133822682131587direct-write-after):2026-10-12_12_51_50-15846649412145844281directWriteByteThreshold = 1 MB)WriteFiles32.5Mrecords)4,040file paths)2.37 GBbacklog)0backlog)300files (445 MBavg)4,040files (35.2 MBavg)Before vs. After Shuffle Step Input (
125.18 GBvs.516.84 KB):https://screenshot-v2.corp.google.com/image/504bv5gm3tlro.png
https://screenshot-v2.corp.google.com/image/3ndvsk8951v6g.png
Before vs. After System Latency (
~2.5 minvs.3–5s):https://screenshot-v2.corp.google.com/image/4mv9vipuam300.png
https://screenshot-v2.corp.google.com/image/6507lv1t9mj68.png