scwhittle commented on code in PR #40543:
URL: https://github.com/apache/beam/pull/40543#discussion_r4478299851


##########
sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java:
##########
@@ -1114,6 +1181,342 @@ public PCollection<FileResult<DestinationT>> 
expand(PCollection<UserT> input) {
     }
   }
 
+  /**
+   * Streaming write that sends large bundles straight to temporary files.
+   *
+   * <p>Small bundles, and records that would exceed the per-bundle writer 
limit, take the usual
+   * grouped path ({@link WriteShardedBundlesToTempFiles} when a shard count 
is set, or the {@link
+   * GroupIntoBatches} auto-sharded path otherwise). All files then drop their 
shard number and are
+   * grouped per destination, so finalization assigns shard numbers across 
both paths.
+   */
+  private class WriteShardedBundlesWithDirectWrite
+      extends PTransform<PCollection<UserT>, 
PCollection<List<FileResult<DestinationT>>>> {
+    private final Coder<DestinationT> destinationCoder;
+    private final Coder<FileResult<DestinationT>> fileResultCoder;
+    private final @Nullable PCollectionView<Integer> numShardsView;
+    private final long thresholdBytes;
+
+    private WriteShardedBundlesWithDirectWrite(
+        Coder<DestinationT> destinationCoder,
+        Coder<FileResult<DestinationT>> fileResultCoder,
+        @Nullable PCollectionView<Integer> numShardsView,
+        long thresholdBytes) {
+      this.destinationCoder = destinationCoder;
+      this.fileResultCoder = fileResultCoder;
+      this.numShardsView = numShardsView;
+      this.thresholdBytes = thresholdBytes;
+    }
+
+    @Override
+    public PCollection<List<FileResult<DestinationT>>> 
expand(PCollection<UserT> input) {
+      TupleTag<FileResult<DestinationT>> directFilesTag = new 
TupleTag<>("directFiles");
+      TupleTag<UserT> smallBundlesTag = new TupleTag<>("smallBundles");
+
+      PCollectionTuple routed =
+          input.apply(
+              "DirectWriteLargeBundles",
+              ParDo.of(
+                      new DirectWriteLargeBundlesFn(
+                          smallBundlesTag, thresholdBytes, input.getCoder()))
+                  .withSideInputs(getSideInputs())
+                  .withOutputTags(
+                      directFilesTag,
+                      TupleTagList.of(ImmutableList.of(smallBundlesTag, 
BAD_RECORD_TAG))));
+      addErrorCollection(routed);
+
+      PCollection<UserT> smallBundles = 
routed.get(smallBundlesTag).setCoder(input.getCoder());
+      PCollection<FileResult<DestinationT>> groupedFiles;
+      if (getComputeNumShards() != null || getNumShardsProvider() != null) {
+        groupedFiles =
+            smallBundles
+                .apply(
+                    "WriteShardedBundlesToTempFiles",
+                    new WriteShardedBundlesToTempFiles(
+                        destinationCoder, fileResultCoder, numShardsView))
+                .apply(
+                    "DropShardNum",
+                    ParDo.of(
+                        new DoFn<FileResult<DestinationT>, 
FileResult<DestinationT>>() {
+                          @ProcessElement
+                          public void process(ProcessContext c) {
+                            c.output(c.element().withShard(UNKNOWN_SHARDNUM));
+                          }
+                        }))
+                .setCoder(fileResultCoder);
+      } else {
+        groupedFiles =
+            writeAutoShardedRecordsToTempFiles(
+                smallBundles,
+                destinationCoder,
+                fileResultCoder,
+                getBatchSizeOrDefault(),
+                getBatchSizeBytesOrDefault(),
+                getBatchMaxBufferingDurationOrDefault());
+      }
+
+      // When the small path goes through GroupIntoShards (GBK), its trigger 
transitions to the
+      // continuation trigger. Set the direct path to the same windowing 
strategy so
+      // FlattenTempFiles does not reject mismatched triggers when user 
pipelines use non-default
+      // triggers.
+      PCollection<FileResult<DestinationT>> directFiles =
+          routed
+              .get(directFilesTag)
+              .setCoder(fileResultCoder)
+              
.setWindowingStrategyInternal(groupedFiles.getWindowingStrategy());
+
+      // Group all temp files per destination so each window/pane firing is 
finalized together
+      // and shard numbers are assigned once across both paths. Every file is 
stamped with this
+      // firing's pane so files from different firings of the same window get 
distinct names.
+      PCollection<FileResult<DestinationT>> allTempFiles =
+          PCollectionList.of(directFiles)
+              .and(groupedFiles)
+              .apply("FlattenTempFiles", Flatten.pCollections())
+              .setCoder(fileResultCoder);
+      return groupTempFilesByDestination(
+          allTempFiles,
+          destinationCoder,
+          fileResultCoder,
+          "GroupTempFilesByDestination",
+          /* stampFiringPane= */ true);
+    }
+  }
+
+  private static class ByteSizeObserver extends ElementByteSizeObserver {
+    private long byteSize;
+
+    @Override
+    protected void reportElementSize(long elementByteSize) {
+      this.byteSize = elementByteSize;
+    }
+  }
+
+  /**
+   * Buffers each bundle's records until their encoded size reaches {@code 
thresholdBytes}. Bundles
+   * that reach it are written directly to temporary files. Records of smaller 
bundles, and records
+   * that would exceed the per-bundle writer limit, are emitted to {@code 
smallBundlesTag} at the
+   * end of the bundle for the grouped path.
+   */
+  private class DirectWriteLargeBundlesFn extends DoFn<UserT, 
FileResult<DestinationT>> {
+    private final TupleTag<UserT> smallBundlesTag;
+    private final long thresholdBytes;
+    private final Coder<UserT> inputCoder;
+
+    private final Counter directBundles = Metrics.counter(WriteFiles.class, 
"directWriteBundles");
+    private final Counter groupedBundles = Metrics.counter(WriteFiles.class, 
"groupedWriteBundles");
+
+    // Per-bundle state, reset in startBundle.
+    private transient List<BufferedRecord<UserT>> buffer;
+    private transient long bufferedBytes;
+    private transient boolean directMode;
+    private transient Map<WriterKey<DestinationT>, Writer<DestinationT, 
OutputT>> writers;
+    private transient List<BufferedRecord<UserT>> spilled;
+
+    DirectWriteLargeBundlesFn(
+        TupleTag<UserT> smallBundlesTag, long thresholdBytes, Coder<UserT> 
inputCoder) {
+      this.smallBundlesTag = smallBundlesTag;
+      this.thresholdBytes = thresholdBytes;
+      this.inputCoder = inputCoder;
+    }
+
+    @StartBundle
+    public void startBundle() {

Review Comment:
   if we aren't leaking the references can we just clear objects instead of 
reallocating?



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java:
##########
@@ -441,6 +455,26 @@ public WriteFiles<UserT, DestinationT, OutputT> 
withBatchMaxBufferingDuration(
     return 
toBuilder().setBatchMaxBufferingDuration(batchMaxBufferingDuration).build();
   }
 
+  /**
+   * Returns a new {@link WriteFiles} that writes large input bundles directly 
to temporary files,

Review Comment:
   that fuses with proceeding stages and writes large inputs from a single 
bundle directly to temporary files



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java:
##########
@@ -1114,6 +1202,318 @@ public PCollection<FileResult<DestinationT>> 
expand(PCollection<UserT> input) {
     }
   }
 
+  /**
+   * Streaming write that sends large bundles straight to temporary files.
+   *
+   * <p>Small bundles, and records that would exceed the per-bundle writer 
limit, take the usual
+   * grouped path ({@link WriteShardedBundlesToTempFiles} when a shard count 
is set, or the {@link
+   * GroupIntoBatches} auto-sharded path otherwise). All files then drop their 
shard number and are
+   * grouped per destination, so finalization assigns shard numbers across 
both paths.
+   */
+  private class WriteShardedBundlesWithDirectWrite
+      extends PTransform<PCollection<UserT>, 
PCollection<List<FileResult<DestinationT>>>> {
+    private final Coder<DestinationT> destinationCoder;
+    private final Coder<FileResult<DestinationT>> fileResultCoder;
+    private final @Nullable PCollectionView<Integer> numShardsView;
+    private final long thresholdBytes;
+
+    private WriteShardedBundlesWithDirectWrite(
+        Coder<DestinationT> destinationCoder,
+        Coder<FileResult<DestinationT>> fileResultCoder,
+        @Nullable PCollectionView<Integer> numShardsView,
+        long thresholdBytes) {
+      this.destinationCoder = destinationCoder;
+      this.fileResultCoder = fileResultCoder;
+      this.numShardsView = numShardsView;
+      this.thresholdBytes = thresholdBytes;
+    }
+
+    @Override
+    public PCollection<List<FileResult<DestinationT>>> 
expand(PCollection<UserT> input) {
+      TupleTag<UserT> smallBundlesTag = new TupleTag<>("smallBundles");
+      TupleTag<UserT> largeBundlesTag = new TupleTag<>("largeBundles");
+      TupleTag<FileResult<DestinationT>> directFilesTag = new 
TupleTag<>("directFiles");
+      TupleTag<UserT> spilledRecordsTag = new TupleTag<>("spilledRecords");
+
+      // Evaluate the threshold per window, pane and destination, i.e. per 
output file.
+      PCollectionTuple lifted =
+          input.apply(
+              "LiftLargeBundles",
+              BundleLifter.of(smallBundlesTag, largeBundlesTag, 
thresholdBytes, input.getCoder())
+                  .withKeyFn(new 
DestinationKeyFn<>(getDynamicDestinations())));
+
+      PCollectionTuple routed =
+          lifted
+              .get(largeBundlesTag)
+              .setCoder(input.getCoder())
+              .apply(
+                  "DirectWriteLargeBundles",
+                  ParDo.of(new DirectWriteRecordsFn(spilledRecordsTag, 
input.getCoder()))
+                      .withSideInputs(getSideInputs())
+                      .withOutputTags(
+                          directFilesTag,
+                          TupleTagList.of(ImmutableList.of(spilledRecordsTag, 
BAD_RECORD_TAG))));
+      addErrorCollection(routed);
+
+      PCollection<UserT> smallBundles =
+          
PCollectionList.of(lifted.get(smallBundlesTag).setCoder(input.getCoder()))
+              .and(routed.get(spilledRecordsTag).setCoder(input.getCoder()))
+              .apply("FlattenSmallBundles", Flatten.pCollections())
+              .apply("CountGroupedBundles", ParDo.of(new 
CountGroupedBundlesFn<>()))
+              .setCoder(input.getCoder());
+      PCollection<FileResult<DestinationT>> groupedFiles;
+      if (getComputeNumShards() != null || getNumShardsProvider() != null) {
+        groupedFiles =
+            smallBundles
+                .apply(
+                    "WriteShardedBundlesToTempFiles",
+                    new WriteShardedBundlesToTempFiles(
+                        destinationCoder, fileResultCoder, numShardsView))
+                .apply(
+                    "DropShardNum",
+                    ParDo.of(
+                        new DoFn<FileResult<DestinationT>, 
FileResult<DestinationT>>() {
+                          @ProcessElement
+                          public void process(ProcessContext c) {
+                            c.output(c.element().withShard(UNKNOWN_SHARDNUM));
+                          }
+                        }))
+                .setCoder(fileResultCoder);
+      } else {
+        groupedFiles =
+            writeAutoShardedRecordsToTempFiles(
+                smallBundles,
+                destinationCoder,
+                fileResultCoder,
+                getBatchSizeOrDefault(),
+                getBatchSizeBytesOrDefault(),
+                getBatchMaxBufferingDurationOrDefault());
+      }
+
+      // When the small path goes through GroupIntoShards (GBK), its trigger 
transitions to the
+      // continuation trigger. Set the direct path to the same windowing 
strategy so
+      // FlattenTempFiles does not reject mismatched triggers when user 
pipelines use non-default
+      // triggers.
+      PCollection<FileResult<DestinationT>> directFiles =
+          routed
+              .get(directFilesTag)
+              .setCoder(fileResultCoder)
+              
.setWindowingStrategyInternal(groupedFiles.getWindowingStrategy());
+
+      // Group all temp files per destination so each window/pane firing is 
finalized together
+      // and shard numbers are assigned once across both paths. Every file is 
stamped with this
+      // firing's pane so files from different firings of the same window get 
distinct names.
+      PCollection<FileResult<DestinationT>> allTempFiles =
+          PCollectionList.of(directFiles)
+              .and(groupedFiles)
+              .apply("FlattenTempFiles", Flatten.pCollections())
+              .setCoder(fileResultCoder);
+      return groupTempFilesByDestination(
+          allTempFiles,
+          destinationCoder,
+          fileResultCoder,
+          "GroupTempFilesByDestination",
+          /* stampFiringPane= */ true);
+    }
+  }
+
+  /**
+   * Key function for {@link BundleLifter}: the record's destination, so that 
together with the
+   * window and pane the threshold is evaluated per output file. Returns 
{@code null} if the
+   * destination cannot be computed, which sends the record to the grouped 
path where the usual
+   * error handling applies.
+   */
+  private static class DestinationKeyFn<UserT, DestinationT>
+      implements SerializableFunction<UserT, @Nullable Object> {
+    private final DynamicDestinations<UserT, DestinationT, ?> 
dynamicDestinations;
+
+    DestinationKeyFn(DynamicDestinations<UserT, DestinationT, ?> 
dynamicDestinations) {
+      this.dynamicDestinations = dynamicDestinations;
+    }
+
+    @Override
+    public @Nullable Object apply(UserT record) {
+      try {
+        // Wrapped so that a null destination (e.g. Void) is still a valid key.
+        return 
java.util.Optional.ofNullable(dynamicDestinations.getDestination(record));
+      } catch (Exception e) {
+        return null;
+      }
+    }
+  }
+
+  /** Passes records through, counting the bundles that take the grouped path. 
*/
+  private static class CountGroupedBundlesFn<T> extends DoFn<T, T> {
+    private final Counter groupedBundles = Metrics.counter(WriteFiles.class, 
"groupedWriteBundles");

Review Comment:
   how interesting is this? can this just be observed through the existing 
pcollection metrics?



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/util/BundleLifter.java:
##########
@@ -57,89 +84,275 @@ public class BundleLifter<T> extends 
PTransform<PCollection<T>, PCollectionTuple
 
   final TupleTag<T> smallBatchTag;
   final TupleTag<T> largeBatchTag;
-  final int threshold;
+  final long threshold;
   final SerializableFunction<T, Integer> elementSizer;
+  final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
   /**
-   * A DoFn that buffers elements within a bundle and outputs them to 
different tags in
-   * finish_bundle based on the total bundle size.
+   * A DoFn that buffers elements per key within a bundle and outputs them to 
the large tag once a
+   * key reaches the threshold, or to the small tag otherwise.
    *
    * @param <T> The type of elements being processed.
    */
   static class BundleLiftDoFn<T> extends DoFn<T, Void> {
     private static final Logger LOG = 
LoggerFactory.getLogger(BundleLiftDoFn.class);
 
+    /** The key shared by all elements when no key function is set. */
+    private static final Object UNKEYED = new Object();
+
     final TupleTag<T> smallBatchTag;
     final TupleTag<T> largeBatchTag;
-    final int threshold;
+    final long threshold;
+    final long maxBufferedBytes;
     final SerializableFunction<T, Integer> elementSizer;
+    final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
-    private transient @MonotonicNonNull List<T> buffer;
-    private transient long bundleSizeBytes;
-    private transient @Nullable MultiOutputReceiver receiver;
+    // Per-bundle state, reset in startBundle.
+    private transient @MonotonicNonNull Map<Object, KeyBuffer<T>> buffers;
+    private transient @MonotonicNonNull Set<Object> liftedKeys;
+    // Reset in startBundle; not transient so SpotBugs doesn't flag it as 
unrestored state.
+    private long bufferedBytes;
 
     BundleLiftDoFn(
         TupleTag<T> smallBatchTag,
         TupleTag<T> largeBatchTag,
-        int threshold,
+        long threshold,
         SerializableFunction<T, Integer> elementSizer) {
+      this(smallBatchTag, largeBatchTag, threshold, elementSizer, null);
+    }
+
+    BundleLiftDoFn(
+        TupleTag<T> smallBatchTag,
+        TupleTag<T> largeBatchTag,
+        long threshold,
+        SerializableFunction<T, Integer> elementSizer,
+        @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn) {
       this.smallBatchTag = smallBatchTag;
       this.largeBatchTag = largeBatchTag;
       this.threshold = threshold;
+      this.maxBufferedBytes = threshold > Long.MAX_VALUE / 2 ? Long.MAX_VALUE 
: 2 * threshold;

Review Comment:
   should we allow the caller to specify this?



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/util/BundleLifter.java:
##########
@@ -57,89 +84,275 @@ public class BundleLifter<T> extends 
PTransform<PCollection<T>, PCollectionTuple
 
   final TupleTag<T> smallBatchTag;
   final TupleTag<T> largeBatchTag;
-  final int threshold;
+  final long threshold;
   final SerializableFunction<T, Integer> elementSizer;
+  final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
   /**
-   * A DoFn that buffers elements within a bundle and outputs them to 
different tags in
-   * finish_bundle based on the total bundle size.
+   * A DoFn that buffers elements per key within a bundle and outputs them to 
the large tag once a
+   * key reaches the threshold, or to the small tag otherwise.
    *
    * @param <T> The type of elements being processed.
    */
   static class BundleLiftDoFn<T> extends DoFn<T, Void> {
     private static final Logger LOG = 
LoggerFactory.getLogger(BundleLiftDoFn.class);
 
+    /** The key shared by all elements when no key function is set. */
+    private static final Object UNKEYED = new Object();
+
     final TupleTag<T> smallBatchTag;
     final TupleTag<T> largeBatchTag;
-    final int threshold;
+    final long threshold;
+    final long maxBufferedBytes;
     final SerializableFunction<T, Integer> elementSizer;
+    final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
-    private transient @MonotonicNonNull List<T> buffer;
-    private transient long bundleSizeBytes;
-    private transient @Nullable MultiOutputReceiver receiver;
+    // Per-bundle state, reset in startBundle.
+    private transient @MonotonicNonNull Map<Object, KeyBuffer<T>> buffers;
+    private transient @MonotonicNonNull Set<Object> liftedKeys;
+    // Reset in startBundle; not transient so SpotBugs doesn't flag it as 
unrestored state.
+    private long bufferedBytes;
 
     BundleLiftDoFn(
         TupleTag<T> smallBatchTag,
         TupleTag<T> largeBatchTag,
-        int threshold,
+        long threshold,
         SerializableFunction<T, Integer> elementSizer) {
+      this(smallBatchTag, largeBatchTag, threshold, elementSizer, null);
+    }
+
+    BundleLiftDoFn(
+        TupleTag<T> smallBatchTag,
+        TupleTag<T> largeBatchTag,
+        long threshold,
+        SerializableFunction<T, Integer> elementSizer,
+        @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn) {
       this.smallBatchTag = smallBatchTag;
       this.largeBatchTag = largeBatchTag;
       this.threshold = threshold;
+      this.maxBufferedBytes = threshold > Long.MAX_VALUE / 2 ? Long.MAX_VALUE 
: 2 * threshold;
       this.elementSizer = elementSizer;
+      this.keyFn = keyFn;
     }
 
     @StartBundle
     public void startBundle() {
-      buffer = new ArrayList<>();
-      receiver = null;
-      bundleSizeBytes = 0L;
+      buffers = new LinkedHashMap<>();
+      liftedKeys = new HashSet<>();
+      bufferedBytes = 0L;
     }
 
     @ProcessElement
-    public void processElement(@Element T element, MultiOutputReceiver mor) {
-      if (receiver == null) {
-        receiver = mor;
+    public void processElement(
+        @Element T element,
+        @Timestamp Instant timestamp,
+        BoundedWindow window,
+        PaneInfo pane,
+        MultiOutputReceiver receiver) {
+      Map<Object, KeyBuffer<T>> buffers =
+          checkStateNotNull(this.buffers, "Buffers should be set by 
startBundle.");
+      Set<Object> liftedKeys =
+          checkStateNotNull(this.liftedKeys, "Lifted keys should be set by 
startBundle.");
+
+      Object key;
+      if (keyFn == null) {
+        key = UNKEYED;
+      } else {
+        @Nullable Object userKey = keyFn.apply(element);
+        if (userKey == null) {
+          receiver.get(smallBatchTag).output(element);
+          return;
+        }
+        key = new WindowedKey(window, pane, userKey);
+      }
+
+      if (liftedKeys.contains(key)) {
+        // This key already reached the threshold in this bundle; stream it 
through.
+        receiver.get(largeBatchTag).output(element);
+        return;
+      }
+
+      KeyBuffer<T> buffer = buffers.get(key);
+      if (buffer == null) {
+        buffer = new KeyBuffer<>();
+        buffers.put(key, buffer);
+      }
+      long elementBytes = elementSizer.apply(element);
+      buffer.add(new BufferedElement<>(element, timestamp, window, pane), 
elementBytes);
+      bufferedBytes += elementBytes;
+
+      if (buffer.sizeBytes >= threshold) {
+        buffers.remove(key);
+        bufferedBytes -= buffer.sizeBytes;
+        liftedKeys.add(key);

Review Comment:
   liftedkeys is going to grow  here.
   
   What about instead transitioning to a finalized keys state tracked by a 
boolean.  Anything that isn't in lifted keys or buffers is sent to small keys 
immediately.



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/util/BundleLifter.java:
##########
@@ -57,89 +84,275 @@ public class BundleLifter<T> extends 
PTransform<PCollection<T>, PCollectionTuple
 
   final TupleTag<T> smallBatchTag;
   final TupleTag<T> largeBatchTag;
-  final int threshold;
+  final long threshold;
   final SerializableFunction<T, Integer> elementSizer;
+  final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
   /**
-   * A DoFn that buffers elements within a bundle and outputs them to 
different tags in
-   * finish_bundle based on the total bundle size.
+   * A DoFn that buffers elements per key within a bundle and outputs them to 
the large tag once a
+   * key reaches the threshold, or to the small tag otherwise.
    *
    * @param <T> The type of elements being processed.
    */
   static class BundleLiftDoFn<T> extends DoFn<T, Void> {
     private static final Logger LOG = 
LoggerFactory.getLogger(BundleLiftDoFn.class);
 
+    /** The key shared by all elements when no key function is set. */
+    private static final Object UNKEYED = new Object();
+
     final TupleTag<T> smallBatchTag;
     final TupleTag<T> largeBatchTag;
-    final int threshold;
+    final long threshold;
+    final long maxBufferedBytes;
     final SerializableFunction<T, Integer> elementSizer;
+    final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
-    private transient @MonotonicNonNull List<T> buffer;
-    private transient long bundleSizeBytes;
-    private transient @Nullable MultiOutputReceiver receiver;
+    // Per-bundle state, reset in startBundle.
+    private transient @MonotonicNonNull Map<Object, KeyBuffer<T>> buffers;
+    private transient @MonotonicNonNull Set<Object> liftedKeys;
+    // Reset in startBundle; not transient so SpotBugs doesn't flag it as 
unrestored state.
+    private long bufferedBytes;
 
     BundleLiftDoFn(
         TupleTag<T> smallBatchTag,
         TupleTag<T> largeBatchTag,
-        int threshold,
+        long threshold,
         SerializableFunction<T, Integer> elementSizer) {
+      this(smallBatchTag, largeBatchTag, threshold, elementSizer, null);
+    }
+
+    BundleLiftDoFn(
+        TupleTag<T> smallBatchTag,
+        TupleTag<T> largeBatchTag,
+        long threshold,
+        SerializableFunction<T, Integer> elementSizer,
+        @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn) {
       this.smallBatchTag = smallBatchTag;
       this.largeBatchTag = largeBatchTag;
       this.threshold = threshold;
+      this.maxBufferedBytes = threshold > Long.MAX_VALUE / 2 ? Long.MAX_VALUE 
: 2 * threshold;
       this.elementSizer = elementSizer;
+      this.keyFn = keyFn;
     }
 
     @StartBundle
     public void startBundle() {
-      buffer = new ArrayList<>();
-      receiver = null;
-      bundleSizeBytes = 0L;
+      buffers = new LinkedHashMap<>();

Review Comment:
   should we prefer clearing these instead of resetting if we're not leaking 
them out of this dofn?



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/util/BundleLifter.java:
##########
@@ -57,89 +84,275 @@ public class BundleLifter<T> extends 
PTransform<PCollection<T>, PCollectionTuple
 
   final TupleTag<T> smallBatchTag;
   final TupleTag<T> largeBatchTag;
-  final int threshold;
+  final long threshold;
   final SerializableFunction<T, Integer> elementSizer;
+  final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
   /**
-   * A DoFn that buffers elements within a bundle and outputs them to 
different tags in
-   * finish_bundle based on the total bundle size.
+   * A DoFn that buffers elements per key within a bundle and outputs them to 
the large tag once a
+   * key reaches the threshold, or to the small tag otherwise.
    *
    * @param <T> The type of elements being processed.
    */
   static class BundleLiftDoFn<T> extends DoFn<T, Void> {
     private static final Logger LOG = 
LoggerFactory.getLogger(BundleLiftDoFn.class);
 
+    /** The key shared by all elements when no key function is set. */
+    private static final Object UNKEYED = new Object();
+
     final TupleTag<T> smallBatchTag;
     final TupleTag<T> largeBatchTag;
-    final int threshold;
+    final long threshold;
+    final long maxBufferedBytes;
     final SerializableFunction<T, Integer> elementSizer;
+    final @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn;
 
-    private transient @MonotonicNonNull List<T> buffer;
-    private transient long bundleSizeBytes;
-    private transient @Nullable MultiOutputReceiver receiver;
+    // Per-bundle state, reset in startBundle.
+    private transient @MonotonicNonNull Map<Object, KeyBuffer<T>> buffers;
+    private transient @MonotonicNonNull Set<Object> liftedKeys;
+    // Reset in startBundle; not transient so SpotBugs doesn't flag it as 
unrestored state.
+    private long bufferedBytes;
 
     BundleLiftDoFn(
         TupleTag<T> smallBatchTag,
         TupleTag<T> largeBatchTag,
-        int threshold,
+        long threshold,
         SerializableFunction<T, Integer> elementSizer) {
+      this(smallBatchTag, largeBatchTag, threshold, elementSizer, null);
+    }
+
+    BundleLiftDoFn(
+        TupleTag<T> smallBatchTag,
+        TupleTag<T> largeBatchTag,
+        long threshold,
+        SerializableFunction<T, Integer> elementSizer,
+        @Nullable SerializableFunction<T, ? extends @Nullable Object> keyFn) {
       this.smallBatchTag = smallBatchTag;
       this.largeBatchTag = largeBatchTag;
       this.threshold = threshold;
+      this.maxBufferedBytes = threshold > Long.MAX_VALUE / 2 ? Long.MAX_VALUE 
: 2 * threshold;
       this.elementSizer = elementSizer;
+      this.keyFn = keyFn;
     }
 
     @StartBundle
     public void startBundle() {
-      buffer = new ArrayList<>();
-      receiver = null;
-      bundleSizeBytes = 0L;
+      buffers = new LinkedHashMap<>();
+      liftedKeys = new HashSet<>();
+      bufferedBytes = 0L;
     }
 
     @ProcessElement
-    public void processElement(@Element T element, MultiOutputReceiver mor) {
-      if (receiver == null) {
-        receiver = mor;
+    public void processElement(
+        @Element T element,
+        @Timestamp Instant timestamp,
+        BoundedWindow window,
+        PaneInfo pane,
+        MultiOutputReceiver receiver) {
+      Map<Object, KeyBuffer<T>> buffers =
+          checkStateNotNull(this.buffers, "Buffers should be set by 
startBundle.");
+      Set<Object> liftedKeys =
+          checkStateNotNull(this.liftedKeys, "Lifted keys should be set by 
startBundle.");
+
+      Object key;
+      if (keyFn == null) {
+        key = UNKEYED;
+      } else {
+        @Nullable Object userKey = keyFn.apply(element);
+        if (userKey == null) {

Review Comment:
   should we use key = UNKEYED in this case instead?



##########
sdks/java/core/src/main/java/org/apache/beam/sdk/io/WriteFiles.java:
##########
@@ -1114,6 +1202,318 @@ public PCollection<FileResult<DestinationT>> 
expand(PCollection<UserT> input) {
     }
   }
 
+  /**
+   * Streaming write that sends large bundles straight to temporary files.
+   *
+   * <p>Small bundles, and records that would exceed the per-bundle writer 
limit, take the usual
+   * grouped path ({@link WriteShardedBundlesToTempFiles} when a shard count 
is set, or the {@link
+   * GroupIntoBatches} auto-sharded path otherwise). All files then drop their 
shard number and are
+   * grouped per destination, so finalization assigns shard numbers across 
both paths.
+   */
+  private class WriteShardedBundlesWithDirectWrite
+      extends PTransform<PCollection<UserT>, 
PCollection<List<FileResult<DestinationT>>>> {
+    private final Coder<DestinationT> destinationCoder;
+    private final Coder<FileResult<DestinationT>> fileResultCoder;
+    private final @Nullable PCollectionView<Integer> numShardsView;
+    private final long thresholdBytes;
+
+    private WriteShardedBundlesWithDirectWrite(
+        Coder<DestinationT> destinationCoder,
+        Coder<FileResult<DestinationT>> fileResultCoder,
+        @Nullable PCollectionView<Integer> numShardsView,
+        long thresholdBytes) {
+      this.destinationCoder = destinationCoder;
+      this.fileResultCoder = fileResultCoder;
+      this.numShardsView = numShardsView;
+      this.thresholdBytes = thresholdBytes;
+    }
+
+    @Override
+    public PCollection<List<FileResult<DestinationT>>> 
expand(PCollection<UserT> input) {
+      TupleTag<UserT> smallBundlesTag = new TupleTag<>("smallBundles");
+      TupleTag<UserT> largeBundlesTag = new TupleTag<>("largeBundles");
+      TupleTag<FileResult<DestinationT>> directFilesTag = new 
TupleTag<>("directFiles");
+      TupleTag<UserT> spilledRecordsTag = new TupleTag<>("spilledRecords");
+
+      // Evaluate the threshold per window, pane and destination, i.e. per 
output file.
+      PCollectionTuple lifted =
+          input.apply(
+              "LiftLargeBundles",
+              BundleLifter.of(smallBundlesTag, largeBundlesTag, 
thresholdBytes, input.getCoder())
+                  .withKeyFn(new 
DestinationKeyFn<>(getDynamicDestinations())));
+
+      PCollectionTuple routed =
+          lifted
+              .get(largeBundlesTag)
+              .setCoder(input.getCoder())
+              .apply(
+                  "DirectWriteLargeBundles",
+                  ParDo.of(new DirectWriteRecordsFn(spilledRecordsTag, 
input.getCoder()))
+                      .withSideInputs(getSideInputs())
+                      .withOutputTags(
+                          directFilesTag,
+                          TupleTagList.of(ImmutableList.of(spilledRecordsTag, 
BAD_RECORD_TAG))));
+      addErrorCollection(routed);
+
+      PCollection<UserT> smallBundles =
+          
PCollectionList.of(lifted.get(smallBundlesTag).setCoder(input.getCoder()))
+              .and(routed.get(spilledRecordsTag).setCoder(input.getCoder()))
+              .apply("FlattenSmallBundles", Flatten.pCollections())
+              .apply("CountGroupedBundles", ParDo.of(new 
CountGroupedBundlesFn<>()))
+              .setCoder(input.getCoder());
+      PCollection<FileResult<DestinationT>> groupedFiles;
+      if (getComputeNumShards() != null || getNumShardsProvider() != null) {
+        groupedFiles =
+            smallBundles
+                .apply(
+                    "WriteShardedBundlesToTempFiles",
+                    new WriteShardedBundlesToTempFiles(
+                        destinationCoder, fileResultCoder, numShardsView))
+                .apply(
+                    "DropShardNum",
+                    ParDo.of(
+                        new DoFn<FileResult<DestinationT>, 
FileResult<DestinationT>>() {
+                          @ProcessElement
+                          public void process(ProcessContext c) {
+                            c.output(c.element().withShard(UNKNOWN_SHARDNUM));
+                          }
+                        }))
+                .setCoder(fileResultCoder);
+      } else {
+        groupedFiles =
+            writeAutoShardedRecordsToTempFiles(
+                smallBundles,
+                destinationCoder,
+                fileResultCoder,
+                getBatchSizeOrDefault(),
+                getBatchSizeBytesOrDefault(),
+                getBatchMaxBufferingDurationOrDefault());
+      }
+
+      // When the small path goes through GroupIntoShards (GBK), its trigger 
transitions to the
+      // continuation trigger. Set the direct path to the same windowing 
strategy so
+      // FlattenTempFiles does not reject mismatched triggers when user 
pipelines use non-default
+      // triggers.
+      PCollection<FileResult<DestinationT>> directFiles =
+          routed
+              .get(directFilesTag)
+              .setCoder(fileResultCoder)
+              
.setWindowingStrategyInternal(groupedFiles.getWindowingStrategy());
+
+      // Group all temp files per destination so each window/pane firing is 
finalized together
+      // and shard numbers are assigned once across both paths. Every file is 
stamped with this
+      // firing's pane so files from different firings of the same window get 
distinct names.
+      PCollection<FileResult<DestinationT>> allTempFiles =
+          PCollectionList.of(directFiles)
+              .and(groupedFiles)
+              .apply("FlattenTempFiles", Flatten.pCollections())
+              .setCoder(fileResultCoder);
+      return groupTempFilesByDestination(
+          allTempFiles,
+          destinationCoder,
+          fileResultCoder,
+          "GroupTempFilesByDestination",
+          /* stampFiringPane= */ true);
+    }
+  }
+
+  /**
+   * Key function for {@link BundleLifter}: the record's destination, so that 
together with the
+   * window and pane the threshold is evaluated per output file. Returns 
{@code null} if the
+   * destination cannot be computed, which sends the record to the grouped 
path where the usual
+   * error handling applies.
+   */
+  private static class DestinationKeyFn<UserT, DestinationT>
+      implements SerializableFunction<UserT, @Nullable Object> {
+    private final DynamicDestinations<UserT, DestinationT, ?> 
dynamicDestinations;
+
+    DestinationKeyFn(DynamicDestinations<UserT, DestinationT, ?> 
dynamicDestinations) {
+      this.dynamicDestinations = dynamicDestinations;
+    }
+
+    @Override
+    public @Nullable Object apply(UserT record) {
+      try {
+        // Wrapped so that a null destination (e.g. Void) is still a valid key.
+        return 
java.util.Optional.ofNullable(dynamicDestinations.getDestination(record));
+      } catch (Exception e) {
+        return null;
+      }
+    }
+  }
+
+  /** Passes records through, counting the bundles that take the grouped path. 
*/
+  private static class CountGroupedBundlesFn<T> extends DoFn<T, T> {
+    private final Counter groupedBundles = Metrics.counter(WriteFiles.class, 
"groupedWriteBundles");
+    private transient boolean counted;
+
+    @StartBundle
+    public void startBundle() {
+      counted = false;
+    }
+
+    @ProcessElement
+    public void processElement(@Element T element, OutputReceiver<T> receiver) 
{
+      if (!counted) {
+        groupedBundles.inc();
+        counted = true;
+      }
+      receiver.output(element);
+    }
+  }
+
+  /**
+   * Writes the records lifted by {@link BundleLifter} directly to temporary 
files, one writer per
+   * window, pane and destination. Records that would exceed the per-bundle 
writer limit are emitted
+   * to {@code spilledRecordsTag} for the grouped path.
+   */
+  private class DirectWriteRecordsFn extends DoFn<UserT, 
FileResult<DestinationT>> {
+    private final TupleTag<UserT> spilledRecordsTag;
+    private final Coder<UserT> inputCoder;
+
+    private final Counter directBundles = Metrics.counter(WriteFiles.class, 
"directWriteBundles");

Review Comment:
   ditto, do we need this beyond the existing pcollection metrics?



-- 
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