This is an automated email from the ASF dual-hosted git repository.

Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new a757f49cb50 [#36841] Spark 4 streaming: Impulse, bounded reads and 
PAssert on the Structured Streaming runner (#40766)
a757f49cb50 is described below

commit a757f49cb50631f0ab7341487eab11cedd3811e2
Author: Tobias Kaymak <[email protected]>
AuthorDate: Wed Oct 28 16:21:00 2026 +0100

    [#36841] Spark 4 streaming: Impulse, bounded reads and PAssert on the 
Structured Streaming runner (#40766)
    
    * [#36841] Spark 4 streaming: Impulse, bounded reads and PAssert on the 
Structured Streaming runner
    
    * Run the Spark ValidatesRunner PostCommits
---
 ...eam_PostCommit_Java_ValidatesRunner_Spark4.json |   2 +-
 ...a_ValidatesRunner_SparkStructuredStreaming.json |   2 +-
 .../io/streaming/BeamReaderCache.java              |  37 ++++-
 .../io/streaming/BeamSourceCheckpoint.java         |  32 ++++-
 .../io/streaming/UnboundedSourceDataset.java       |  35 ++++-
 .../translation/PipelineTranslatorStreaming.java   |  16 +--
 .../translation/StreamingEvaluationContext.java    |   3 +-
 .../streaming/ReadUnboundedTranslator.java         |  20 ++-
 .../io/streaming/BeamMicroBatchSourceTest.java     |  18 ++-
 .../io/streaming/TestUnboundedSource.java          |  37 ++++-
 .../PipelineTranslatorStreamingTest.java           |  17 ---
 .../streaming/PAssertStreamingTest.java            | 149 ++++++++++++++++++++
 .../streaming/StatefulParDoStreamingTest.java      |   3 +-
 .../SparkStructuredStreamingPipelineOptions.java   |   3 +-
 .../translation/SparkTransformOverrides.java       | 156 +++++++++++++++++++++
 15 files changed, 470 insertions(+), 60 deletions(-)

diff --git 
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json 
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json
index e0266d62f2e..f1ba03a243e 100644
--- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json
+++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json
@@ -1,4 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "modification": 4
+  "modification": 5
 }
diff --git 
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
 
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
index 1b832d7f19c..65728e1581d 100644
--- 
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
+++ 
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
@@ -12,5 +12,5 @@
   "https://github.com/apache/beam/pull/40103": "noting that PR #40103 should 
run this test",
   "https://github.com/apache/beam/issues/40427": "Spark ValidatesRunner tests 
run serially",
   "https://github.com/apache/beam/pull/40281": "noting that PR #40281 should 
run this test",
-  "https://github.com/apache/beam/issues/36841": "Spark 4 streaming GroupByKey"
+  "https://github.com/apache/beam/issues/36841": "Spark 4 streaming Impulse 
and PAssert"
 }
diff --git 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java
 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java
index e961ff2f380..8348d04d289 100644
--- 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java
+++ 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java
@@ -32,6 +32,7 @@ import org.apache.beam.sdk.io.UnboundedSource;
 import org.apache.beam.sdk.io.UnboundedSource.CheckpointMark;
 import org.apache.beam.sdk.io.UnboundedSource.UnboundedReader;
 import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -231,6 +232,11 @@ public final class BeamReaderCache {
     private final long idleTimeoutMillis;
     private final LongSupplier committedEpoch;
     private boolean started;
+    private boolean exhausted;
+    private boolean positionExhausted;
+    private boolean doneEligible;
+    private boolean doneWritten;
+    private boolean sentinelEmitted;
     private boolean inBatch;
     private boolean moved;
     private long positionEpoch;
@@ -258,11 +264,31 @@ public final class BeamReaderCache {
 
     public synchronized boolean startOrAdvance() throws IOException {
       moved = true;
-      if (!started) {
-        started = true;
-        return reader.start();
+      boolean hasElement = started ? reader.advance() : reader.start();
+      started = true;
+      if (!hasElement && 
!reader.getWatermark().isBefore(GlobalWindow.INSTANCE.maxTimestamp())) {
+        exhausted = true;
       }
-      return reader.advance();
+      return hasElement;
+    }
+
+    /**
+     * Claims the one time end of stream sentinel for this reader once the 
driver has seen every
+     * split done.
+     */
+    public synchronized boolean claimSentinel(boolean emitSentinel) {
+      if (!emitSentinel || sentinelEmitted) {
+        return false;
+      }
+      sentinelEmitted = true;
+      return true;
+    }
+
+    /**
+     * Whether a committed batch exhausted this reader and its done marker has 
not been written yet.
+     */
+    synchronized boolean shouldWriteDone() {
+      return doneEligible && !doneWritten;
     }
 
     /**
@@ -288,6 +314,7 @@ public final class BeamReaderCache {
       if (positionEpoch != epoch || moved) {
         return false;
       }
+      doneEligible |= positionExhausted;
       inBatch = true;
       lastUsedMillis = System.currentTimeMillis();
       return true;
@@ -298,6 +325,8 @@ public final class BeamReaderCache {
       positionEpoch = endEpoch;
       positionMark = codedMark;
       pendingMark = mark;
+      positionExhausted = exhausted;
+      doneWritten |= doneEligible;
       moved = false;
       inBatch = false;
       lastUsedMillis = System.currentTimeMillis();
diff --git 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamSourceCheckpoint.java
 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamSourceCheckpoint.java
index d667976edce..8b58a0e5107 100644
--- 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamSourceCheckpoint.java
+++ 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamSourceCheckpoint.java
@@ -42,7 +42,8 @@ import org.slf4j.LoggerFactory;
  *
  * <p>{@code <location>/splits} pins the split list, written once by the 
driver. {@code
  * <location>/marks/<epoch>/<splitId>} holds the coded checkpoint mark of a 
split at the end of the
- * batch ending at that epoch. The epoch Spark last committed is read from 
Spark's own {@code
+ * batch ending at that epoch. {@code <location>/done/<splitId>} marks a split 
whose reader reached
+ * the end of the global window. The epoch Spark last committed is read from 
Spark's own {@code
  * commits} and {@code offsets} logs two levels up. All IO goes through 
Spark's {@link
  * CheckpointFileManager}, writes are atomic renames.
  */
@@ -52,6 +53,7 @@ public final class BeamSourceCheckpoint {
 
   private static final String SPLITS_FILE = "splits";
   private static final String MARKS_DIR = "marks";
+  private static final String DONE_DIR = "done";
   private static final String SPARK_COMMITS_DIR = "commits";
   private static final String SPARK_OFFSETS_DIR = "offsets";
   private static final String SERIALIZED_VOID_OFFSET = "-";
@@ -64,6 +66,7 @@ public final class BeamSourceCheckpoint {
   private final Path root;
   private final Path splitsPath;
   private final Path marksRoot;
+  private final Path doneRoot;
 
   /** Every mark epoch strictly below the value is known to be deleted, -1 for 
unknown. */
   private volatile long purgeFloor = -1L;
@@ -74,6 +77,7 @@ public final class BeamSourceCheckpoint {
     this.fm = CheckpointFileManager.create(root, hadoopConf);
     this.splitsPath = new Path(root, SPLITS_FILE);
     this.marksRoot = new Path(root, MARKS_DIR);
+    this.doneRoot = new Path(root, DONE_DIR);
   }
 
   public String location() {
@@ -129,6 +133,32 @@ public final class BeamSourceCheckpoint {
     return read(path);
   }
 
+  /** Writes the one time done marker of {@code splitId}, idempotent. */
+  public void writeDone(int splitId) throws IOException {
+    Path path = new Path(doneRoot, Integer.toString(splitId));
+    try {
+      write(path, new byte[0], true);
+    } catch (FileNotFoundException e) {
+      fm.mkdirs(doneRoot);
+      write(path, new byte[0], true);
+    }
+  }
+
+  /** Whether every split in {@code [0, numSplits)} has a marker under {@code 
done/}. */
+  public boolean allSplitsDone(int numSplits) throws IOException {
+    if (!fm.exists(doneRoot)) {
+      return false;
+    }
+    int count = 0;
+    for (FileStatus status : fm.list(doneRoot)) {
+      long splitId = parseEpoch(status.getPath().getName());
+      if (splitId >= 0 && splitId < numSplits && ++count == numSplits) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   /**
    * The end epoch of this source in the last batch Spark committed, or -1 if 
there is none or the
    * logs cannot be read. The location is {@code <root>/sources/<index>}, the 
batch id is the
diff --git 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java
 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java
index 33106824c10..3ef35762748 100644
--- 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java
+++ 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java
@@ -36,6 +36,7 @@ import org.apache.beam.sdk.coders.Coder;
 import org.apache.beam.sdk.io.UnboundedSource;
 import org.apache.beam.sdk.io.UnboundedSource.CheckpointMark;
 import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.BackOff;
 import org.apache.beam.sdk.util.BackOffUtils;
 import org.apache.beam.sdk.util.FluentBackoff;
@@ -86,7 +87,9 @@ import scala.reflect.ClassTag;
  * TIMESTAMP} holding the event timestamp of that element.
  *
  * <p>The event time watermark is declared here and only here. Spark 4 rejects 
a second {@code
- * withWatermark} further down the plan, so downstream translators must never 
call it again.
+ * withWatermark} further down the plan, so downstream translators must never 
call it again. Once
+ * every split of the source is done, each reader emits one empty payload row 
at {@link
+ * BoundedWindow#TIMESTAMP_MAX_VALUE} to advance the watermark, which is 
dropped at decode.
  */
 public final class UnboundedSourceDataset {
 
@@ -305,6 +308,7 @@ public final class UnboundedSourceDataset {
     private final AtomicLong purgeRequested = new AtomicLong();
 
     private long epoch;
+    private boolean emitSentinel;
     private @Nullable List<UnboundedSource<T, ?>> splits;
 
     BeamMicroBatchStream(BeamTable<T> table, String checkpointLocation) {
@@ -343,6 +347,13 @@ public final class UnboundedSourceDataset {
         LOG.warn(
             "Failed to prepare mark directory of epoch {} at {}.", endEpoch, 
checkpointLocation, e);
       }
+      if (!emitSentinel) {
+        try {
+          emitSentinel = checkpoint.allSplitsDone(pinned.size());
+        } catch (IOException e) {
+          LOG.warn("Failed to list done splits at {}.", checkpointLocation, e);
+        }
+      }
       long[] quotas = splitQuotas(table.maxRecordsPerBatch, pinned.size(), 
endEpoch);
       InputPartition[] partitions = new InputPartition[pinned.size()];
       for (int i = 0; i < pinned.size(); i++) {
@@ -358,7 +369,8 @@ public final class UnboundedSourceDataset {
                 endEpoch,
                 quotas[i],
                 table.maxBatchDurationMillis,
-                table.readerIdleTimeoutMillis);
+                table.readerIdleTimeoutMillis,
+                emitSentinel);
       }
       return partitions;
     }
@@ -495,6 +507,7 @@ public final class UnboundedSourceDataset {
 
     final long maxBatchDurationMillis;
     final long readerIdleTimeoutMillis;
+    final boolean emitSentinel;
 
     BeamInputPartition(
         UnboundedSource<T, ?> split,
@@ -507,7 +520,8 @@ public final class UnboundedSourceDataset {
         long endEpoch,
         long maxRecords,
         long maxBatchDurationMillis,
-        long readerIdleTimeoutMillis) {
+        long readerIdleTimeoutMillis,
+        boolean emitSentinel) {
       this.split = split;
       this.coder = coder;
       this.options = options;
@@ -519,6 +533,7 @@ public final class UnboundedSourceDataset {
       this.maxRecords = maxRecords;
       this.maxBatchDurationMillis = maxBatchDurationMillis;
       this.readerIdleTimeoutMillis = readerIdleTimeoutMillis;
+      this.emitSentinel = emitSentinel;
     }
 
     @Override
@@ -573,6 +588,7 @@ public final class UnboundedSourceDataset {
     private final long endEpoch;
     private final long maxRecords;
     private final long maxBatchDurationMillis;
+    private final boolean emitSentinel;
 
     private long recordsRead;
     private long deadlineMillis = -1L;
@@ -586,6 +602,7 @@ public final class UnboundedSourceDataset {
       this.endEpoch = partition.endEpoch;
       this.maxRecords = partition.maxRecords;
       this.maxBatchDurationMillis = partition.maxBatchDurationMillis;
+      this.emitSentinel = partition.emitSentinel;
       PipelineOptions options = partition.options.value().get();
       Configuration conf = partition.hadoopConf.value().value();
       BeamSourceCheckpoint checkpoint =
@@ -624,6 +641,10 @@ public final class UnboundedSourceDataset {
           current = toRow();
           return true;
         }
+        if (cached.claimSentinel(emitSentinel)) {
+          current = sentinelRow();
+          return true;
+        }
         if (backOff == null) {
           backOff = backOff(remaining);
         }
@@ -684,6 +705,9 @@ public final class UnboundedSourceDataset {
       CheckpointMark mark = cached.reader().getCheckpointMark();
       byte[] codedMark = encodeMark(split, mark);
       checkpoint.writeMark(splitId, endEpoch, codedMark);
+      if (cached.shouldWriteDone()) {
+        checkpoint.writeDone(splitId);
+      }
       cached.endBatch(endEpoch, mark, codedMark);
       LOG.debug("Beam reader {} read {} record(s) up to epoch {}.", key, 
recordsRead, endEpoch);
     }
@@ -717,5 +741,10 @@ public final class UnboundedSourceDataset {
       // Spark stores TimestampType as microseconds.
       return new GenericInternalRow(new Object[] {payload, 
timestamp.getMillis() * 1000L});
     }
+
+    private static InternalRow sentinelRow() {
+      return new GenericInternalRow(
+          new Object[] {new byte[0], 
BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis() * 1000L});
+    }
   }
 }
diff --git 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java
 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java
index 6f2d14a69b8..4d93741b636 100644
--- 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java
+++ 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java
@@ -29,12 +29,12 @@ import org.apache.beam.sdk.state.TimerSpec;
 import org.apache.beam.sdk.transforms.Combine;
 import org.apache.beam.sdk.transforms.DoFn;
 import org.apache.beam.sdk.transforms.GroupByKey;
-import org.apache.beam.sdk.transforms.Impulse;
 import org.apache.beam.sdk.transforms.PTransform;
 import org.apache.beam.sdk.transforms.ParDo;
 import org.apache.beam.sdk.transforms.reflect.DoFnSignature;
 import org.apache.beam.sdk.transforms.reflect.DoFnSignatures;
 import org.apache.beam.sdk.transforms.windowing.DefaultTrigger;
+import org.apache.beam.sdk.transforms.windowing.Never.NeverTrigger;
 import org.apache.beam.sdk.util.construction.SplittableParDo;
 import org.apache.beam.sdk.values.KV;
 import org.apache.beam.sdk.values.PCollection;
@@ -93,17 +93,6 @@ public class PipelineTranslatorStreaming extends 
PipelineTranslatorCommon {
       return READ_UNBOUNDED;
     }
 
-    if (transform instanceof SplittableParDo.PrimitiveBoundedRead) {
-      throw new UnsupportedOperationException(
-          "Bounded Read (Read.from(BoundedSource), Create with two or more 
elements)"
-              + NOT_SUPPORTED);
-    }
-
-    if (transform instanceof Impulse) {
-      throw new UnsupportedOperationException(
-          "Impulse (Create with fewer than two elements, PAssert)" + 
NOT_SUPPORTED);
-    }
-
     if (transform instanceof GroupByKey) {
       return GROUP_BY_KEY;
     }
@@ -175,7 +164,8 @@ public class PipelineTranslatorStreaming extends 
PipelineTranslatorCommon {
   private static void checkSupportedGroupByKey(PCollection<?> input) {
     WindowingStrategy<?, ?> strategy = input.getWindowingStrategy();
     checkNotMergingWindows(strategy, "GroupByKey");
-    if (!(strategy.getTrigger() instanceof DefaultTrigger)) {
+    if (!(strategy.getTrigger() instanceof DefaultTrigger
+        || strategy.getTrigger() instanceof NeverTrigger)) {
       throw new UnsupportedOperationException(
           "Custom triggers (" + strategy.getTrigger() + ")" + NOT_SUPPORTED);
     }
diff --git 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/StreamingEvaluationContext.java
 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/StreamingEvaluationContext.java
index 2ca54a1e948..b5057cd260a 100644
--- 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/StreamingEvaluationContext.java
+++ 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/StreamingEvaluationContext.java
@@ -30,6 +30,7 @@ import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.beam.runners.spark.SparkCommonPipelineOptions;
 import 
org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions;
+import org.apache.beam.sdk.Pipeline;
 import org.apache.beam.sdk.annotations.Internal;
 import org.apache.hadoop.fs.Path;
 import org.apache.spark.sql.Dataset;
@@ -179,7 +180,7 @@ public class StreamingEvaluationContext extends 
EvaluationContext {
         } catch (StreamingQueryException e) {
           LOG.error("Streaming query {} terminated with an exception.", 
query.id(), e);
           stop();
-          throw new RuntimeException(e);
+          throw new Pipeline.PipelineExecutionException(e.getCause() != null ? 
e.getCause() : e);
         }
       }
     }
diff --git 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java
 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java
index eb1421b4554..2281bbfdec3 100644
--- 
a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java
+++ 
b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java
@@ -19,6 +19,8 @@ package 
org.apache.beam.runners.spark.structuredstreaming.translation.streaming;
 
 import static org.apache.spark.sql.functions.col;
 
+import java.util.Collections;
+import java.util.Iterator;
 import org.apache.beam.runners.spark.coders.CoderHelpers;
 import 
org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions;
 import 
org.apache.beam.runners.spark.structuredstreaming.io.streaming.UnboundedSourceDataset;
@@ -31,7 +33,7 @@ import org.apache.beam.sdk.values.PBegin;
 import org.apache.beam.sdk.values.PCollection;
 import org.apache.beam.sdk.values.WindowedValue;
 import org.apache.beam.sdk.values.WindowedValues;
-import org.apache.spark.api.java.function.MapFunction;
+import org.apache.spark.api.java.function.FlatMapFunction;
 import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Encoder;
 import org.apache.spark.sql.Encoders;
@@ -76,13 +78,16 @@ public class ReadUnboundedTranslator<T>
     Dataset<WindowedValue<T>> dataset =
         rows.select(col(UnboundedSourceDataset.COL_PAYLOAD))
             .as(Encoders.BINARY())
-            .map(new DecodePayload<>(payloadCoder), encoder);
+            .flatMap(new DecodePayload<>(payloadCoder), encoder);
 
     cxt.putDataset(output, dataset);
   }
 
-  /** Decodes the binary payload column back into a Beam {@code 
WindowedValue}. */
-  private static final class DecodePayload<T> implements MapFunction<byte[], 
WindowedValue<T>> {
+  /**
+   * Decodes the binary payload column back into a Beam {@code WindowedValue}, 
dropping the empty
+   * end of stream sentinel payload.
+   */
+  private static final class DecodePayload<T> implements 
FlatMapFunction<byte[], WindowedValue<T>> {
     private final Coder<WindowedValue<T>> coder;
 
     DecodePayload(Coder<WindowedValue<T>> coder) {
@@ -90,8 +95,11 @@ public class ReadUnboundedTranslator<T>
     }
 
     @Override
-    public WindowedValue<T> call(byte[] payload) {
-      return CoderHelpers.fromByteArray(payload, coder);
+    public Iterator<WindowedValue<T>> call(byte[] payload) {
+      if (payload.length == 0) {
+        return Collections.emptyIterator();
+      }
+      return Collections.singleton(CoderHelpers.fromByteArray(payload, 
coder)).iterator();
     }
   }
 }
diff --git 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java
 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java
index 559deb80803..07c823068c7 100644
--- 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java
+++ 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java
@@ -71,7 +71,6 @@ import org.apache.spark.sql.Dataset;
 import org.apache.spark.sql.Encoders;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.SparkSession;
-import org.apache.spark.sql.catalyst.InternalRow;
 import org.apache.spark.sql.catalyst.plans.logical.EventTimeWatermark;
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
 import org.apache.spark.sql.connector.read.InputPartition;
@@ -547,7 +546,13 @@ public class BeamMicroBatchSourceTest implements 
Serializable {
                 (batch, batchId) -> {
                   List<List<Row>> target = BATCHES.get(queryName);
                   if (target != null) {
-                    target.add(batch.collectAsList());
+                    List<Row> rows = new ArrayList<>();
+                    for (Row row : batch.collectAsList()) {
+                      if (((byte[]) row.getAs(COL_PAYLOAD)).length > 0) {
+                        rows.add(row);
+                      }
+                    }
+                    target.add(rows);
                   }
                 })
         .queryName(queryName)
@@ -766,7 +771,8 @@ public class BeamMicroBatchSourceTest implements 
Serializable {
         end,
         3L,
         30_000L,
-        600_000L);
+        600_000L,
+        false);
   }
 
   private static List<String> readBatch(BeamInputPartition<String> partition) 
throws IOException {
@@ -778,8 +784,10 @@ public class BeamMicroBatchSourceTest implements 
Serializable {
   private static void drainInto(BeamPartitionReader<String> reader, 
List<String> values)
       throws IOException {
     while (reader.next()) {
-      InternalRow row = reader.get();
-      values.add(decode(row.getBinary(0)));
+      byte[] payload = reader.get().getBinary(0);
+      if (payload.length > 0) {
+        values.add(decode(payload));
+      }
     }
     reader.close();
   }
diff --git 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/TestUnboundedSource.java
 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/TestUnboundedSource.java
index 98f62501f37..8bc257d1872 100644
--- 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/TestUnboundedSource.java
+++ 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/TestUnboundedSource.java
@@ -41,8 +41,9 @@ import org.joda.time.Instant;
 
 /**
  * In memory unbounded source for tests. Splits into one sub source per shard. 
Elements are strings
- * formatted as tag, shard, index. Watermark freezes after exhaustion. Marks 
record finalized
- * positions under tag and shard.
+ * formatted as tag, shard, index. Watermark advances to TIMESTAMP_MAX_VALUE 
after exhaustion, while
+ * an {@link #extend} below the declared count holds the watermark. Marks 
record finalized positions
+ * under tag and shard.
  */
 public final class TestUnboundedSource extends UnboundedSource<String, 
TestUnboundedSource.Mark> {
   private static final long serialVersionUID = 1L;
@@ -62,20 +63,27 @@ public final class TestUnboundedSource extends 
UnboundedSource<String, TestUnbou
   private final int shard;
   private final int shards;
   private final int perShard;
+  private final int @Nullable [] shardCounts;
 
   public TestUnboundedSource(String tag, int shards, int count) {
-    this(tag, -1, shards, count / shards);
+    this(tag, -1, shards, count / shards, null);
+  }
+
+  public TestUnboundedSource(String tag, int[] shardCounts) {
+    this(tag, -1, shardCounts.length, 0, shardCounts.clone());
   }
 
   public static void extend(String tag, int count) {
     EXTENDED.put(tag, count);
   }
 
-  private TestUnboundedSource(String tag, int shard, int shards, int perShard) 
{
+  private TestUnboundedSource(
+      String tag, int shard, int shards, int perShard, int @Nullable [] 
shardCounts) {
     this.tag = tag;
     this.shard = shard;
     this.shards = shards;
     this.perShard = perShard;
+    this.shardCounts = shardCounts;
   }
 
   public static Set<String> elements(String tag, int shards, int count) {
@@ -88,6 +96,16 @@ public final class TestUnboundedSource extends 
UnboundedSource<String, TestUnbou
     return elements;
   }
 
+  public static Set<String> elements(String tag, int[] shardCounts) {
+    Set<String> elements = new HashSet<>();
+    for (int shard = 0; shard < shardCounts.length; shard++) {
+      for (int index = 0; index < shardCounts[shard]; index++) {
+        elements.add(element(tag, shard, index));
+      }
+    }
+    return elements;
+  }
+
   public static String element(String tag, int shard, int index) {
     return tag + "-" + shard + "-" + index;
   }
@@ -133,7 +151,8 @@ public final class TestUnboundedSource extends 
UnboundedSource<String, TestUnbou
     }
     List<TestUnboundedSource> splits = new ArrayList<>();
     for (int i = 0; i < shards; i++) {
-      splits.add(new TestUnboundedSource(tag, i, shards, perShard));
+      int count = shardCounts != null ? shardCounts[i] : perShard;
+      splits.add(new TestUnboundedSource(tag, i, shards, count, null));
     }
     return splits;
   }
@@ -200,6 +219,7 @@ public final class TestUnboundedSource extends 
UnboundedSource<String, TestUnbou
     private final TestUnboundedSource source;
     private int next;
     private int current = -1;
+    private boolean exhausted;
 
     Reader(TestUnboundedSource source, int next) {
       this.source = source;
@@ -222,6 +242,7 @@ public final class TestUnboundedSource extends 
UnboundedSource<String, TestUnbou
         current = next++;
         return true;
       }
+      exhausted = next >= source.perShard;
       return false;
     }
 
@@ -238,11 +259,15 @@ public final class TestUnboundedSource extends 
UnboundedSource<String, TestUnbou
       if (current < 0) {
         throw new NoSuchElementException();
       }
-      return new Instant(BASE_MILLIS + (source.shard * limit() + current) * 
INTERVAL_MILLIS);
+      return new Instant(
+          BASE_MILLIS + (source.shard * source.perShard + current) * 
INTERVAL_MILLIS);
     }
 
     @Override
     public Instant getWatermark() {
+      if (exhausted) {
+        return BoundedWindow.TIMESTAMP_MAX_VALUE;
+      }
       return current < 0 ? BoundedWindow.TIMESTAMP_MIN_VALUE : 
getCurrentTimestamp();
     }
 
diff --git 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java
 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java
index a5afa7c1a4a..d18f069cfbe 100644
--- 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java
+++ 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java
@@ -34,7 +34,6 @@ import org.apache.beam.sdk.state.TimeDomain;
 import org.apache.beam.sdk.state.TimerSpec;
 import org.apache.beam.sdk.state.TimerSpecs;
 import org.apache.beam.sdk.transforms.Count;
-import org.apache.beam.sdk.transforms.Create;
 import org.apache.beam.sdk.transforms.DoFn;
 import org.apache.beam.sdk.transforms.GroupByKey;
 import org.apache.beam.sdk.transforms.ParDo;
@@ -87,22 +86,6 @@ public class PipelineTranslatorStreamingTest implements 
Serializable {
     throw new AssertionError("missing " + expected);
   }
 
-  @Test
-  public void rejectsImpulseFromCreate() throws Exception {
-    SparkStructuredStreamingPipelineOptions o = 
StreamingTestUtils.streamingOptions(temp);
-    Pipeline p = Pipeline.create(o);
-    p.apply(Create.of("rejected"));
-    assertUnsupported(p, "Impulse");
-  }
-
-  @Test
-  public void rejectsBoundedReadFromCreate() throws Exception {
-    SparkStructuredStreamingPipelineOptions o = 
StreamingTestUtils.streamingOptions(temp);
-    Pipeline p = Pipeline.create(o);
-    p.apply(Create.of("rejected", "too"));
-    assertUnsupported(p, "Bounded Read");
-  }
-
   @Test
   public void rejectsUnsupportedStatefulFeatures() throws Exception {
     Object[][] cases = {
diff --git 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/PAssertStreamingTest.java
 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/PAssertStreamingTest.java
new file mode 100644
index 00000000000..79a836f1c02
--- /dev/null
+++ 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/PAssertStreamingTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package 
org.apache.beam.runners.spark.structuredstreaming.translation.streaming;
+
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.Serializable;
+import org.apache.beam.runners.spark.StreamingTest;
+import org.apache.beam.runners.spark.structuredstreaming.SparkSessionRule;
+import 
org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions;
+import 
org.apache.beam.runners.spark.structuredstreaming.io.streaming.TestUnboundedSource;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.io.Read;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.windowing.FixedWindows;
+import org.apache.beam.sdk.transforms.windowing.Window;
+import org.apache.beam.sdk.values.PCollection;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Throwables;
+import org.joda.time.Duration;
+import org.junit.After;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** End to end tests for {@link PAssert} and {@link Create} on Spark 4 
Structured Streaming. */
+@RunWith(JUnit4.class)
+@Category(StreamingTest.class)
+public class PAssertStreamingTest implements Serializable {
+
+  @ClassRule public static final SparkSessionRule SESSION = new 
SparkSessionRule();
+
+  @Rule public transient TemporaryFolder tempFolder = new TemporaryFolder();
+
+  @Rule public final transient TestPipeline pipeline = TestPipeline.create();
+
+  private static final String TAG_GLOBAL = "passert-global";
+  private static final String TAG_UNEVEN = "passert-uneven";
+  private static final String TAG_WINDOWED = "passert-windowed";
+  private static final String TAG_EMPTY = "passert-empty";
+  private static final String TAG_FAIL = "passert-fail";
+
+  private SparkStructuredStreamingPipelineOptions options() throws IOException 
{
+    SparkStructuredStreamingPipelineOptions options =
+        StreamingTestUtils.streamingOptions(tempFolder);
+    options.setTestMode(true);
+    return options;
+  }
+
+  @After
+  public void tearDown() {
+    TestUnboundedSource.forget(TAG_GLOBAL);
+    TestUnboundedSource.forget(TAG_UNEVEN);
+    TestUnboundedSource.forget(TAG_WINDOWED);
+    TestUnboundedSource.forget(TAG_EMPTY);
+    TestUnboundedSource.forget(TAG_FAIL);
+  }
+
+  @Test
+  public void testContainsInAnyOrderGlobalWindow() throws Exception {
+    PCollection<String> out =
+        pipeline.apply("Read", Read.from(new TestUnboundedSource(TAG_GLOBAL, 
2, 4)));
+    
PAssert.that(out).containsInAnyOrder(TestUnboundedSource.elements(TAG_GLOBAL, 
2, 4));
+    pipeline.run(options());
+  }
+
+  @Test
+  public void testContainsInAnyOrderUnevenShards() throws Exception {
+    int[] shardCounts = {1, 4};
+    PCollection<String> out =
+        pipeline.apply("Read", Read.from(new TestUnboundedSource(TAG_UNEVEN, 
shardCounts)));
+    
PAssert.that(out).containsInAnyOrder(TestUnboundedSource.elements(TAG_UNEVEN, 
shardCounts));
+    SparkStructuredStreamingPipelineOptions options = options();
+    options.setMaxRecordsPerBatch(2L);
+    pipeline.run(options);
+  }
+
+  @Test
+  public void testContainsInAnyOrderFixedWindows() throws Exception {
+    PCollection<String> out =
+        pipeline
+            .apply("Read", Read.from(new TestUnboundedSource(TAG_WINDOWED, 1, 
4)))
+            .apply("Window", 
Window.into(FixedWindows.of(Duration.millis(2000))));
+    
PAssert.that(out).containsInAnyOrder(TestUnboundedSource.elements(TAG_WINDOWED, 
1, 4));
+    pipeline.run(options());
+  }
+
+  @Test
+  public void testEmpty() throws Exception {
+    PCollection<String> out =
+        pipeline.apply("Read", Read.from(new TestUnboundedSource(TAG_EMPTY, 1, 
0)));
+    PAssert.that(out).empty();
+    pipeline.run(options());
+  }
+
+  @Test
+  public void testFailingAssertionFailsPipeline() throws Exception {
+    PCollection<String> out =
+        pipeline.apply("Read", Read.from(new TestUnboundedSource(TAG_FAIL, 1, 
2)));
+    PAssert.that(out).containsInAnyOrder("wrong");
+    SparkStructuredStreamingPipelineOptions options = options();
+    Pipeline.PipelineExecutionException thrown =
+        assertThrows(Pipeline.PipelineExecutionException.class, () -> 
pipeline.run(options));
+    assertTrue(
+        "Expected PAssert AssertionError in causal chain of " + thrown,
+        Throwables.getCausalChain(thrown).stream()
+            .anyMatch(
+                t ->
+                    t instanceof AssertionError
+                        && t.getMessage() != null
+                        && t.getMessage().contains("wrong")));
+  }
+
+  @Test
+  public void testCreateSingleElement() throws Exception {
+    PCollection<String> out = pipeline.apply(Create.of("only"));
+    PAssert.that(out).containsInAnyOrder("only");
+    pipeline.run(options());
+  }
+
+  @Test
+  public void testCreateMultipleElements() throws Exception {
+    PCollection<String> out = pipeline.apply(Create.of("one", "two", "three"));
+    PAssert.that(out).containsInAnyOrder("one", "two", "three");
+    pipeline.run(options());
+  }
+}
diff --git 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java
 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java
index 5679950e189..0e30d6ed791 100644
--- 
a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java
+++ 
b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java
@@ -125,11 +125,12 @@ public class StatefulParDoStreamingTest implements 
Serializable {
     String c1 = StreamingTestUtils.newCollectorId("r1");
     String c2 = StreamingTestUtils.newCollectorId("r2");
 
+    TestUnboundedSource.extend(TAG_RESTART, 3);
     Pipeline p1 =
         pipeline(
             StreamingTestUtils.streamingOptions(checkpoint),
             TAG_RESTART,
-            3,
+            10,
             new RestartTestFn(),
             c1);
     assertEquals(PipelineResult.State.DONE, 
StreamingTestUtils.run(p1).getState());
diff --git 
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/SparkStructuredStreamingPipelineOptions.java
 
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/SparkStructuredStreamingPipelineOptions.java
index fb0192dba86..4d93fde3f8d 100644
--- 
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/SparkStructuredStreamingPipelineOptions.java
+++ 
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/SparkStructuredStreamingPipelineOptions.java
@@ -42,7 +42,8 @@ public interface SparkStructuredStreamingPipelineOptions 
extends SparkCommonPipe
 
   @Description(
       "Watermark delay in milliseconds applied to event timestamps of 
streaming sources "
-          + "(streaming mode only).")
+          + "(streaming mode only). Must be under one day for end of stream 
global window results "
+          + "to complete.")
   @Default.Long(0)
   long getWatermarkDelayMillis();
 
diff --git 
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/SparkTransformOverrides.java
 
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/SparkTransformOverrides.java
index a60aa59efd6..c53b89d4030 100644
--- 
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/SparkTransformOverrides.java
+++ 
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/SparkTransformOverrides.java
@@ -17,15 +17,34 @@
  */
 package org.apache.beam.runners.spark.structuredstreaming.translation;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.coders.ByteArrayCoder;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.io.BoundedSource;
+import org.apache.beam.sdk.io.Read;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.runners.AppliedPTransform;
 import org.apache.beam.sdk.runners.PTransformOverride;
+import org.apache.beam.sdk.runners.PTransformOverrideFactory;
+import org.apache.beam.sdk.transforms.Impulse;
 import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.construction.PTransformMatchers;
 import org.apache.beam.sdk.util.construction.PTransformTranslation;
+import org.apache.beam.sdk.util.construction.ReplacementOutputs;
 import org.apache.beam.sdk.util.construction.SplittableParDo;
 import org.apache.beam.sdk.util.construction.SplittableParDoNaiveBounded;
+import 
org.apache.beam.sdk.util.construction.UnboundedReadFromBoundedSource.BoundedToUnboundedSourceAdapter;
 import org.apache.beam.sdk.util.construction.UnsupportedOverrideFactory;
+import org.apache.beam.sdk.values.PBegin;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollection.IsBounded;
+import org.apache.beam.sdk.values.TupleTag;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import org.joda.time.Instant;
 
 /** {@link PTransform} overrides for Spark runner. */
 @SuppressWarnings({
@@ -50,7 +69,144 @@ class SparkTransformOverrides {
               PTransformOverride.of(
                   
PTransformMatchers.urnEqualTo(PTransformTranslation.SPLITTABLE_PROCESS_KEYED_URN),
                   new SplittableParDoNaiveBounded.OverrideFactory()));
+    } else {
+      builder
+          .add(
+              PTransformOverride.of(
+                  
PTransformMatchers.classEqualTo(SplittableParDo.PrimitiveBoundedRead.class),
+                  new BoundedToUnboundedReadOverrideFactory()))
+          .add(
+              PTransformOverride.of(
+                  PTransformMatchers.classEqualTo(Impulse.class), new 
ImpulseOverrideFactory()));
     }
     return builder.build();
   }
+
+  private static class BoundedToUnboundedReadOverrideFactory<T>
+      implements PTransformOverrideFactory<
+          PBegin, PCollection<T>, SplittableParDo.PrimitiveBoundedRead<T>> {
+    @Override
+    public PTransformReplacement<PBegin, PCollection<T>> 
getReplacementTransform(
+        AppliedPTransform<PBegin, PCollection<T>, 
SplittableParDo.PrimitiveBoundedRead<T>>
+            transform) {
+      return PTransformReplacement.of(
+          transform.getPipeline().begin(),
+          new StreamingBoundedRead<>(transform.getTransform().getSource()));
+    }
+
+    @Override
+    public Map<PCollection<?>, ReplacementOutput> mapOutputs(
+        Map<TupleTag<?>, PCollection<?>> outputs, PCollection<T> newOutput) {
+      return ReplacementOutputs.singleton(outputs, newOutput);
+    }
+  }
+
+  private static class ImpulseOverrideFactory
+      implements PTransformOverrideFactory<PBegin, PCollection<byte[]>, 
Impulse> {
+    @Override
+    public PTransformReplacement<PBegin, PCollection<byte[]>> 
getReplacementTransform(
+        AppliedPTransform<PBegin, PCollection<byte[]>, Impulse> transform) {
+      return PTransformReplacement.of(
+          transform.getPipeline().begin(), new StreamingBoundedRead<>(new 
ImpulseSource()));
+    }
+
+    @Override
+    public Map<PCollection<?>, ReplacementOutput> mapOutputs(
+        Map<TupleTag<?>, PCollection<?>> outputs, PCollection<byte[]> 
newOutput) {
+      return ReplacementOutputs.singleton(outputs, newOutput);
+    }
+  }
+
+  /**
+   * Adapts a {@link BoundedSource} to an unbounded read while keeping the 
output bounded. {@link
+   * SplittableParDo.PrimitiveUnboundedRead} is applied directly because the 
{@link Read} to
+   * primitive conversion runs before {@code replaceTransforms}.
+   */
+  private static class StreamingBoundedRead<T> extends PTransform<PBegin, 
PCollection<T>> {
+    private final BoundedSource<T> source;
+
+    StreamingBoundedRead(BoundedSource<T> source) {
+      this.source = source;
+      source.validate();
+    }
+
+    @Override
+    public final PCollection<T> expand(PBegin input) {
+      return Pipeline.applyTransform(
+              input,
+              new SplittableParDo.PrimitiveUnboundedRead<>(
+                  Read.from(new BoundedToUnboundedSourceAdapter<>(source))))
+          .setIsBoundedInternal(IsBounded.BOUNDED);
+    }
+  }
+
+  /** Single element {@link BoundedSource} backing {@link Impulse} in 
streaming mode. */
+  private static final class ImpulseSource extends BoundedSource<byte[]> {
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    public List<? extends BoundedSource<byte[]>> split(
+        long desiredBundleSizeBytes, PipelineOptions options) {
+      return Collections.singletonList(this);
+    }
+
+    @Override
+    public long getEstimatedSizeBytes(PipelineOptions options) {
+      return 1L;
+    }
+
+    @Override
+    public BoundedReader<byte[]> createReader(PipelineOptions options) {
+      return new ImpulseReader(this);
+    }
+
+    @Override
+    public void validate() {}
+
+    @Override
+    public Coder<byte[]> getOutputCoder() {
+      return ByteArrayCoder.of();
+    }
+  }
+
+  private static final class ImpulseReader extends 
BoundedSource.BoundedReader<byte[]> {
+    private final ImpulseSource source;
+    private boolean done;
+
+    ImpulseReader(ImpulseSource source) {
+      this.source = source;
+    }
+
+    @Override
+    public boolean start() {
+      if (done) {
+        return false;
+      }
+      done = true;
+      return true;
+    }
+
+    @Override
+    public boolean advance() {
+      return false;
+    }
+
+    @Override
+    public byte[] getCurrent() {
+      return new byte[0];
+    }
+
+    @Override
+    public Instant getCurrentTimestamp() {
+      return BoundedWindow.TIMESTAMP_MIN_VALUE;
+    }
+
+    @Override
+    public void close() {}
+
+    @Override
+    public BoundedSource<byte[]> getCurrentSource() {
+      return source;
+    }
+  }
 }

Reply via email to