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

je-ik pushed a commit to branch feat/18479-kafka-streams-runner-skeleton
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to 
refs/heads/feat/18479-kafka-streams-runner-skeleton by this push:
     new 65a2e400c73 [GSoC 2026] Kafka Streams runner: bound a source poll in 
time, not only in elements (#39761)
65a2e400c73 is described below

commit 65a2e400c736ce207a072918f5bd3899237e5ca3
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Sun Aug 16 22:09:28 2026 +0500

    [GSoC 2026] Kafka Streams runner: bound a source poll in time, not only in 
elements (#39761)
    
    * [GSoC 2026] Kafka Streams runner: bound a source poll in time, not only 
in elements
    
    
    --readMaxPollTimeMs bounds the turn in time as well; whichever bound comes
    first ends it.
---
 .../kafka/streams/KafkaStreamsPipelineOptions.java | 17 +++++++
 .../kafka/streams/translation/ReadTranslator.java  |  2 +
 .../translation/UnboundedReadProcessor.java        | 49 ++++++++++++++++++--
 .../streams/translation/UnboundedReadTest.java     | 54 ++++++++++++++++++++++
 .../en/documentation/runners/kafkastreams.md       |  1 +
 5 files changed, 118 insertions(+), 5 deletions(-)

diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
index 180e1ef028f..99454b3a658 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
@@ -58,6 +58,23 @@ public interface KafkaStreamsPipelineOptions extends 
PortablePipelineOptions {
 
   void setReadMaxElementsPerPoll(int readMaxElementsPerPoll);
 
+  @Description(
+      "How long one turn of reading an unbounded source may take, in 
milliseconds, before the"
+          + " source yields the Kafka Streams thread. A source is polled from 
a punctuator"
+          + " scheduled every 50ms, and the same thread runs the rest of the 
topology, so a turn"
+          + " that overruns that interval is already due again when it returns 
and fires straight"
+          + " away: the source then holds the thread and the stages below it 
are never scheduled,"
+          + " which shows up as a pipeline that reads steadily and emits 
nothing at all rather"
+          + " than one that falls behind. Roughly, the source takes this 
fraction of a 50ms"
+          + " interval, so the default of 10ms leaves the thread four fifths 
of its time."
+          + " --readMaxElementsPerPoll bounds the same turn by count; 
whichever bound is reached"
+          + " first ends it, and a count alone cannot bound the time because 
how long an element"
+          + " takes depends on the pipeline below.")
+  @Default.Integer(10)
+  int getReadMaxPollTimeMs();
+
+  void setReadMaxPollTimeMs(int readMaxPollTimeMs);
+
   @Description(
       "How long the consumer group waits before deciding an instance has gone, 
in milliseconds."
           + " This is the floor on how quickly work moves to another instance 
after one is lost,"
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadTranslator.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadTranslator.java
index b6620203b2d..9a727e82a70 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadTranslator.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadTranslator.java
@@ -138,6 +138,7 @@ class ReadTranslator implements PTransformTranslator {
     Coder<CheckpointT> checkpointCoder = 
readableSource.getCheckpointMarkCoder();
     int maxElementsPerPoll = 
context.getPipelineOptions().getReadMaxElementsPerPoll();
     int checkpointEveryNPolls = 
context.getPipelineOptions().getReadCheckpointNumBundles();
+    int maxPollTimeMs = context.getPipelineOptions().getReadMaxPollTimeMs();
 
     topology.addSource(
         sourceNodeName,
@@ -157,6 +158,7 @@ class ReadTranslator implements PTransformTranslator {
                 transformId,
                 maxElementsPerPoll,
                 checkpointEveryNPolls,
+                maxPollTimeMs,
                 context.getTerminationTracker()),
         sourceNodeName);
     topology.addStateStore(
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadProcessor.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadProcessor.java
index 96aa1bf0c69..4f8c5f10556 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadProcessor.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadProcessor.java
@@ -79,6 +79,9 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
   /** How often the source is polled. */
   private static final Duration POLL_INTERVAL = Duration.ofMillis(50);
 
+  /** Elements read between two checks of whether the turn is out of time. */
+  private static final int ELEMENTS_BETWEEN_DEADLINE_CHECKS = 64;
+
   private final UnboundedSource<T, CheckpointT> source;
   private final SerializablePipelineOptions options;
   // See ReadProcessor: a source produces decoded objects, but the downstream 
stage's harness input
@@ -90,6 +93,7 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
   private final String transformId;
   private final int maxElementsPerPoll;
   private final int checkpointEveryNPolls;
+  private final int maxPollTimeMs;
 
   private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
   private @Nullable KeyValueStore<String, byte[]> checkpointStore;
@@ -115,6 +119,7 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
       String transformId,
       int maxElementsPerPoll,
       int checkpointEveryNPolls,
+      int maxPollTimeMs,
       TerminationTracker terminationTracker) {
     this.terminationReporter = new TerminationReporter(terminationTracker, 
transformId);
     this.source = source;
@@ -126,6 +131,7 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
     this.transformId = transformId;
     this.maxElementsPerPoll = maxElementsPerPoll;
     this.checkpointEveryNPolls = checkpointEveryNPolls;
+    this.maxPollTimeMs = maxPollTimeMs;
   }
 
   @Override
@@ -157,6 +163,16 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
    * Streams thread would never get back to committing or to the rest of the 
topology. So at most
    * {@link #checkpointEveryNPolls} batches are taken before yielding, which 
is also where the
    * checkpoint mark is stored, and the next punctuation carries on from there.
+   *
+   * <p>That batch bound is a count, and a count cannot bound the time: how 
long an element takes is
+   * decided by the pipeline underneath it, which the source knows nothing 
about. A punctuator is
+   * expected to be quick, and this one runs on the thread that also serves 
the rest of the
+   * topology, so a turn that overruns its own {@link #POLL_INTERVAL} is due 
again as soon as it
+   * returns and runs once more instead of the tasks below it. Measured on a 
grouping pipeline, a
+   * turn of 200 elements took 3ms and held the thread 6% of the time, while a 
turn of 5000 took
+   * 57ms and held it 89%, and the pipeline read tens of millions of elements 
while emitting none.
+   * {@link #maxPollTimeMs} bounds the turn in time as well, and whichever 
bound is reached first
+   * ends it.
    */
   private void poll() {
     if (exhausted) {
@@ -164,8 +180,9 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
     }
     ProcessorContext<byte[], KStreamsPayload<?>> ctx = 
checkInitialized(context);
     UnboundedReader<T> currentReader = ensureReader();
+    long deadline = System.currentTimeMillis() + maxPollTimeMs;
     for (int batch = 0; batch < checkpointEveryNPolls; batch++) {
-      int emitted = readBatch(ctx, currentReader);
+      int emitted = readBatch(ctx, currentReader, deadline);
       Instant watermark = currentReader.getWatermark();
       forwardWatermarkIfAdvanced(ctx, watermark);
       if (!watermark.isBefore(BoundedWindow.TIMESTAMP_MAX_VALUE)) {
@@ -181,24 +198,46 @@ class UnboundedReadProcessor<T, CheckpointT extends 
CheckpointMark>
         return;
       }
       if (emitted < maxElementsPerPoll) {
-        // Short batch: the source has nothing more for now, so store what was 
read and wait for
-        // the next punctuation rather than spinning on a reader that keeps 
returning false.
+        // Short batch: either the source has nothing more for now, or the 
turn ran out of time.
+        // Either way, store what was read and wait for the next punctuation 
rather than spinning
+        // on a reader that keeps returning false.
         if (emitted > 0) {
           storeCheckpoint(currentReader);
         }
         return;
       }
+      if (System.currentTimeMillis() >= deadline) {
+        // A full batch and the turn is out of time: yield with the position 
recorded, so the next
+        // punctuation carries on rather than this one running the thread out 
from under the rest
+        // of the topology.
+        storeCheckpoint(currentReader);
+        return;
+      }
     }
     // Yielded on the batch bound rather than on an empty source, so record 
the position reached.
     storeCheckpoint(currentReader);
   }
 
-  /** Forwards up to {@link #maxElementsPerPoll} elements, returning how many 
were available. */
+  /**
+   * Forwards up to {@link #maxElementsPerPoll} elements, returning how many 
were available.
+   *
+   * <p>Stops early if the turn's deadline passes, since one batch can be long 
enough on its own to
+   * overrun it. The clock is read every {@link 
#ELEMENTS_BETWEEN_DEADLINE_CHECKS} elements rather
+   * than every element, which bounds the overshoot to that many elements 
without putting a clock
+   * read in front of each one.
+   */
   private int readBatch(
-      ProcessorContext<byte[], KStreamsPayload<?>> ctx, UnboundedReader<T> 
currentReader) {
+      ProcessorContext<byte[], KStreamsPayload<?>> ctx,
+      UnboundedReader<T> currentReader,
+      long deadline) {
     int emitted = 0;
     try {
       while (emitted < maxElementsPerPoll) {
+        if (emitted % ELEMENTS_BETWEEN_DEADLINE_CHECKS == 0
+            && emitted > 0
+            && System.currentTimeMillis() >= deadline) {
+          break;
+        }
         // start() positions the reader on its first element; advance() moves 
to the next. Either
         // returning false means nothing is available right now — not that the 
source is finished,
         // which is the difference from a bounded read.
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadTest.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadTest.java
index b668ee972d5..01e1a3146e3 100644
--- 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadTest.java
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/UnboundedReadTest.java
@@ -21,6 +21,7 @@ import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.lessThan;
 
 import java.io.IOException;
 import java.time.Duration;
@@ -122,6 +123,59 @@ public class UnboundedReadTest {
     }
   }
 
+  /** A pipeline whose source may read {@code elementsPerPoll} elements and 
run for {@code ms}. */
+  private static Pipeline pipelineWithPollBounds(int elementsPerPoll, int 
maxPollTimeMs) {
+    KafkaStreamsPipelineOptions options =
+        
KafkaStreamsTestRunner.testOptions().as(KafkaStreamsPipelineOptions.class);
+    options.setReadMaxElementsPerPoll(elementsPerPoll);
+    options.setReadMaxPollTimeMs(maxPollTimeMs);
+    Pipeline pipeline = Pipeline.create(options);
+    pipeline
+        .apply("read", Read.from(CountingSource.unbounded()))
+        .apply("record", ParDo.of(new RecordFn()));
+    return pipeline;
+  }
+
+  /** Drives one turn of the wall clock and reports how many elements the 
source produced. */
+  private static int elementsInOneTurn(Pipeline pipeline) {
+    KafkaStreamsTranslationContext context = 
KafkaStreamsTestRunner.translate(pipeline);
+    try (TopologyTestDriver driver =
+        new TopologyTestDriver(
+            context.getTopology(), 
KafkaStreamsTestRunner.streamsConfig(pipeline))) {
+      driver.advanceWallClockTime(Duration.ofMillis(100));
+    }
+    return RECEIVED.size();
+  }
+
+  /**
+   * The element bound cannot bound the time a turn takes, because how long an 
element takes is
+   * decided by the pipeline below the source. Left on the count alone, a 
source with data always
+   * available runs the full count every turn, overruns the punctuation 
interval, and is due again
+   * the moment it returns — so it keeps the thread and the rest of the 
topology never runs.
+   */
+  @Test
+  public void aPollOutOfTimeYieldsBeforeReachingItsElementBound() {
+    // A turn that is out of time before it starts, so what stops it can only 
be the time bound.
+    int elements = elementsInOneTurn(pipelineWithPollBounds(1_000, 0));
+
+    assertThat(
+        "the source should have yielded, not run to its element bound",
+        elements,
+        is(lessThan(1_000)));
+    assertThat("the source should still have made progress", elements, 
is(greaterThan(0)));
+  }
+
+  /** The time bound only cuts a turn short; with time to spare the element 
bound still applies. */
+  @Test
+  public void aPollWithTimeToSpareReachesItsElementBound() {
+    int elements = elementsInOneTurn(pipelineWithPollBounds(100, 60_000));
+
+    assertThat(
+        "a turn with time to spare should read at least a full batch",
+        elements,
+        is(greaterThan(99)));
+  }
+
   @Test
   public void aSourceThatReachesTheEndOfTimeStopsBeingPolled() {
     // CountingSource.unbounded() with a limit reports the terminal watermark 
once it has produced
diff --git a/website/www/site/content/en/documentation/runners/kafkastreams.md 
b/website/www/site/content/en/documentation/runners/kafkastreams.md
index bef1f852094..058e85b6d46 100644
--- a/website/www/site/content/en/documentation/runners/kafkastreams.md
+++ b/website/www/site/content/en/documentation/runners/kafkastreams.md
@@ -119,6 +119,7 @@ Named as Java spells them below; from Python the same 
options are in snake case,
 | `topicReplicationFactor` | `1` | Replication factor for those topics. |
 | `maxBundleSize` | `1000` | Elements per bundle, and elements taken per poll 
of an unbounded source. |
 | `maxBundleTimeMs` | `1000` | Intended cap on how long a bundle may stay 
open. **Not applied yet** — see below. |
+| `readMaxPollTimeMs` | `10` | How long one turn of reading an unbounded 
source may take before it yields the Kafka Streams thread. A source is polled 
every 50ms and shares its thread with the rest of the topology, so a turn that 
overruns that interval leaves the stages below it unscheduled; a bound on 
elements alone cannot bound the time. |
 | `readCheckpointNumBundles` | `10` | Polls of an unbounded source between 
stores of its checkpoint mark. Larger values replay more after a restart. |
 | `stateDir` | temp directory | Where Kafka Streams keeps local state. |
 

Reply via email to