ntopousis opened a new issue, #39370:
URL: https://github.com/apache/beam/issues/39370

   🤖 This report and proposed patch were prepared with an AI coding agent from 
a production investigation. Identifiers and application-specific details are 
intentionally omitted; the Dataflow job and support-case identifiers can be 
shared privately with the Google Dataflow team.
   
   ### What happened?
   
   A bounded Java pipeline using Apache Beam 2.64.0 on Google Cloud Dataflow 
took multiple hours to finalize several otherwise-completed unwindowed 
`TextIO.write()` transforms using runner-determined sharding.
   
   The distinguishing factor was not side-input byte size. It was the number of 
temporary-file metadata records entering `WriteFiles/GatherTempFileResults`.
   
   | Output | `FileResult` count | Observed status |
   |---|---:|---|
   | A | 5,119 | completed |
   | B | 23,633 | completed |
   | C | 47,285 | completed |
   | D | 89,677 | still finalizing |
   | E | 97,106 | still finalizing |
   
   One corresponding internal side-input materialization was approximately 18 
MiB. The two largest finalizers were not deadlocked: they advanced 
near-linearly at roughly 200–225 `FileResult` records per minute, making 
finalization take many hours.
   
   Google Cloud support's backend analysis reported that the job was spending 
substantial time reading and retrying GCS files. Repeated worker stacks during 
this phase included:
   
   ```text
   GoogleCloudStorageReadChannel
     -> IsmReaderImpl
     -> IsmSideInputReader
     -> IterableLikeCoder.registerByteSizeObserver
     -> OutputObjectAndByteCounter
   ```
   
   This was not an application-created `PCollectionView`. It was the internal 
side input introduced by bounded, unwindowed `WriteFiles`.
   
   We have not proved whether the dominant defect is the SDK's use of a global 
side input, Dataflow's access pattern for a high-cardinality ISM iterable, or 
their interaction. The proposed SDK patch below removes that interaction while 
deliberately leaving finalization semantics unchanged.
   
   ### Relevant SDK path
   
   Both fixed sharding and bounded runner-determined sharding feed their 
`FileResult`s into `GatherTempFileResults`:
   
   
https://github.com/apache/beam/blob/v2.64.0/sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java#L469-L490
   
   For unwindowed writes, `GatherResults` reshuffles the records, creates 
`View.asIterable()`, reifies the view in the global window, and copies the 
iterable into a `List`:
   
   
https://github.com/apache/beam/blob/v2.64.0/sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java#L527-L570
   
   `FinalizeFn` then copies that list again before finalizing all destinations 
and moving the files:
   
   
https://github.com/apache/beam/blob/v2.64.0/sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java#L1335-L1384
   
   The same global side-input path remains on master as of 
`7eef1304b19c5a47c12354476c4319df5183908d`:
   
   
https://github.com/apache/beam/blob/7eef1304b19c5a47c12354476c4319df5183908d/sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java#L534-L577
   
   PR #26289 introduced `View.asIterable()` in place of `View.asList()` to 
reduce the cost of small writes, while preserving a downstream `ListCoder` for 
update compatibility:
   
   https://github.com/apache/beam/pull/26289
   
   This report concerns the high-cardinality failure mode that remains in that 
path.
   
   ### Reproduction shape
   
   The production trigger can be approximated with a bounded pipeline that 
induces tens of thousands of runner bundles and then performs an unwindowed 
write without explicit sharding:
   
   ```java
   PCollection<String> records =
       pipeline
           .apply(GenerateSequence.from(0).to(NUM_RECORDS))
           .apply(/* produce fixed-size records */)
           .apply(Reshuffle.viaRandomKey());
   
   records.apply(TextIO.write().to("gs://BUCKET/reproduction/output"));
   // Intentionally no withNumShards() and no withWindowedWrites().
   ```
   
   The exact `FileResult` count is runner-dependent, so the meaningful 
reproduction condition is the cardinality entering `GatherTempFileResults`, not 
only the number of input records.
   
   ### Expected behavior
   
   Finalization necessarily performs work proportional to the number of 
temporary files, but an approximately 18 MiB metadata collection should not 
take hours to consume. The gather should use a normal shuffle or another bulk 
main-input path rather than a remote iterable side-input access pattern with 
very high per-element overhead.
   
   ### Proposed narrow SDK patch
   
   The accompanying draft patch changes only the bounded, unwindowed gather 
transport:
   
   1. Gather `FileResult`s into bundle-sized lists.
   2. Add one explicitly coded empty-list marker so empty writes still reach 
finalization.
   3. Flatten and key those lists by `Void`.
   4. Use `GroupByKey` as a main-input shuffle/checkpoint.
   5. Flatten the grouped bundle lists back into the existing 
`PCollection<List<FileResult<...>>>` contract.
   6. Avoid the redundant copy in `FinalizeFn`.
   
   The patch retains `ListCoder` and leaves the existing finalizer and 
`FileBasedSink.WriteOperation` behavior intact. It therefore preserves:
   
   - empty unwindowed writes producing at least one shard unless `skipIfEmpty` 
is enabled;
   - missing empty-shard creation for fixed sharding;
   - runner-determined shard numbering and final filename semantics;
   - dynamic destinations and filename-policy side inputs;
   - retry-safe rename options;
   - output-filename visibility after finalization;
   - whole-temporary-directory cleanup after all renames.
   
   A structural regression test asserts that the unwindowed 
`GatherTempFileResults` subtree no longer contains 
`View.CreatePCollectionView`. Existing `WriteFilesTest` coverage exercises 
empty writes, fixed sharding, missing shards, dynamic destinations, filenames, 
and cleanup.
   
   ### Deliberate limitation
   
   This is a narrow mitigation, not a complete finalization redesign. It still 
produces one O(N) list and uses one logical finalizer. Truly memory-bounded, 
parallel finalization would require a two-phase design because shard counts and 
final names must be established before parallel renames, missing empty shards 
must be generated exactly once, and the shared unwindowed temporary directory 
cannot be deleted until every rename chunk completes.
   
   A Dataflow-side improvement to sequential ISM reads, range prefetch, or 
coder byte-size observation may also be appropriate to protect pipelines on 
released SDK versions.
   
   ### Questions for maintainers
   
   1. Is the global side-input gather intentional for any reason beyond 
ensuring an empty write still invokes finalization?
   2. Is the empty-marker plus main-input `GroupByKey` approach acceptable as a 
narrow first fix?
   3. Does the Dataflow team recognize this `IsmReaderImpl` / 
`IterableLikeCoder.registerByteSizeObserver` stack as a runner-side 
read-amplification pattern?
   4. Should chunked/parallel finalization be tracked as a separate follow-up 
after removing the side-input bottleneck?
   
   ### Issue Priority
   
   Priority: 2 (default / most bugs should be filed as P2)
   
   ### Issue Components
   
   - [x] Component: Java SDK
   - [x] Component: IO connector
   - [x] Component: Google Cloud Dataflow Runner
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to