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 5e65d4772fb [GSoC 2026] Kafka Streams runner #39141: Add GroupByKey
(GlobalWindow, fire at watermark)
5e65d4772fb is described below
commit 5e65d4772fb72265ec1d6ebe3235f6777a30bec6
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Tue Jun 30 14:21:02 2026 +0500
[GSoC 2026] Kafka Streams runner #39141: Add GroupByKey (GlobalWindow, fire
at watermark)
---
.../GroupByKeyBroadcastPartitioner.java | 62 +++++++
.../streams/translation/GroupByKeyProcessor.java | 196 +++++++++++++++++++++
.../streams/translation/GroupByKeyTranslator.java | 130 ++++++++++++++
.../KafkaStreamsPipelineTranslator.java | 1 +
.../streams/translation/ShuffleByKeyProcessor.java | 86 +++++++++
.../kafka/streams/translation/GroupByKeyTest.java | 184 +++++++++++++++++++
.../KafkaStreamsPipelineTranslatorTest.java | 16 +-
7 files changed, 667 insertions(+), 8 deletions(-)
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyBroadcastPartitioner.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyBroadcastPartitioner.java
new file mode 100644
index 00000000000..b5ddcf2536a
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyBroadcastPartitioner.java
@@ -0,0 +1,62 @@
+/*
+ * 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.kafka.streams.translation;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import org.apache.kafka.common.utils.Utils;
+import org.apache.kafka.streams.processor.StreamPartitioner;
+
+/**
+ * Partitions records on the GroupByKey repartition topic.
+ *
+ * <ul>
+ * <li><b>data</b> records go to the single partition selected by hashing
the (already encoded
+ * Beam key) Kafka record key — the same scheme Kafka's default
partitioner uses — so every
+ * value of a key lands together;
+ * <li><b>watermark</b> reports are broadcast to <i>every</i> partition, so
each downstream
+ * GroupByKey task observes the terminal watermark and fires its keys.
+ * </ul>
+ *
+ * @param <T> the data element type carried by data payloads
+ */
+class GroupByKeyBroadcastPartitioner<T> implements StreamPartitioner<byte[],
KStreamsPayload<T>> {
+
+ @Override
+ public Integer partition(String topic, byte[] key, KStreamsPayload<T> value,
int numPartitions) {
+ // Required by the interface but unused: Kafka Streams calls partitions()
(overridden below)
+ // when it is present. Kept consistent with the data-hash path for safety.
+ return key == null ? 0 : Utils.toPositive(Utils.murmur2(key)) %
numPartitions;
+ }
+
+ @Override
+ public Optional<Set<Integer>> partitions(
+ String topic, byte[] key, KStreamsPayload<T> value, int numPartitions) {
+ if (value.isWatermark()) {
+ Set<Integer> all = new HashSet<>();
+ for (int partition = 0; partition < numPartitions; partition++) {
+ all.add(partition);
+ }
+ return Optional.of(all);
+ }
+ int partition = Utils.toPositive(Utils.murmur2(key)) % numPartitions;
+ return Optional.of(Collections.singleton(partition));
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
new file mode 100644
index 00000000000..a6c0cb8c406
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
@@ -0,0 +1,196 @@
+/*
+ * 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.kafka.streams.translation;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.CoderException;
+import org.apache.beam.sdk.coders.IterableCoder;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
+import org.apache.beam.sdk.util.CoderUtils;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.sdk.values.WindowedValues;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.KeyValueStore;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Instant;
+
+/**
+ * Executes a {@code GroupByKey} (GlobalWindow, default trigger, no allowed
lateness).
+ *
+ * <p>Records arrive on the repartition topic keyed by the encoded Beam key,
so every value of a key
+ * is co-located here. Each value is appended to a per-key buffer in a Kafka
Streams state store.
+ * Watermark reports are fed to a {@link WatermarkManager}; when the input
watermark reaches {@link
+ * BoundedWindow#TIMESTAMP_MAX_VALUE} (the end of the global window) every
buffered key is emitted
+ * once as {@code KV<K, Iterable<V>>} and the buffer cleared, then the
watermark is forwarded
+ * downstream.
+ *
+ * <p>Buffering whole value lists and re-encoding on each append is O(n^2) per
key; fine for this
+ * first GroupByKey, and replaced when this moves to runner-core {@code
GroupAlsoByWindow}.
+ */
+class GroupByKeyProcessor
+ implements Processor<byte[], KStreamsPayload<?>, byte[],
KStreamsPayload<?>> {
+
+ private final String stateStoreName;
+ private final Coder<Object> keyCoder;
+ private final IterableCoder<@Nullable Object> bufferCoder;
+
+ private final WatermarkManager watermarkManager = new WatermarkManager();
+ private Instant lastForwardedWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
+ // The global window fires exactly once, when the watermark first reaches
its end. Later watermark
+ // reports (e.g. the same terminal watermark broadcast across repartition
partitions) must not
+ // re-fire. This flag is in-memory only; restart correctness comes from the
state store plus
+ // exactly-once-v2: the buffered values and consumer offsets are committed
atomically, and the
+ // store is empty once a key has fired, so a restart cannot double-emit.
Persisting watermark
+ // holds is part of the separate WatermarkManager persistence work, not this
initial GroupByKey.
+ private boolean fired = false;
+
+ private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
+ private @Nullable KeyValueStore<byte[], byte[]> store;
+
+ GroupByKeyProcessor(
+ String stateStoreName, Coder<Object> keyCoder, Coder<@Nullable Object>
valueCoder) {
+ this.stateStoreName = stateStoreName;
+ this.keyCoder = keyCoder;
+ this.bufferCoder = IterableCoder.of(valueCoder);
+ }
+
+ @Override
+ public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
+ this.context = context;
+ this.store = context.getStateStore(stateStoreName);
+ }
+
+ @Override
+ public void process(Record<byte[], KStreamsPayload<?>> record) {
+ KStreamsPayload<?> payload = record.value();
+ if (payload.isData()) {
+ byte[] encodedKey = record.key();
+ Object element = payload.getData().getValue();
+ if (encodedKey == null || element == null) {
+ throw new IllegalStateException("GroupByKey data record is missing its
key or value");
+ }
+ appendValue(encodedKey, element);
+ return;
+ }
+ WatermarkPayload report = payload.asWatermark();
+ watermarkManager.observe(
+ report.getSourcePartition(),
+ new Instant(report.getWatermarkMillis()),
+ report.getTotalSourcePartitions());
+ Instant advanced = watermarkManager.advance();
+ if (!fired && !advanced.isBefore(BoundedWindow.TIMESTAMP_MAX_VALUE)) {
+ fireAll(record);
+ fired = true;
+ }
+ if (advanced.isAfter(lastForwardedWatermark)) {
+ lastForwardedWatermark = advanced;
+ forwardWatermark(record, advanced.getMillis());
+ }
+ }
+
+ private void appendValue(byte[] encodedKey, Object kvObject) {
+ KV<?, ?> kv = (KV<?, ?>) kvObject;
+ KeyValueStore<byte[], byte[]> kvStore = checkInitialized(store);
+ byte[] existing = kvStore.get(encodedKey);
+ List<@Nullable Object> values = existing == null ? new ArrayList<>() :
decodeBuffer(existing);
+ values.add(kv.getValue());
+ kvStore.put(encodedKey, encodeBuffer(values));
+ }
+
+ private void fireAll(Record<byte[], KStreamsPayload<?>> trigger) {
+ // NOTE: this emits every buffered key in a single watermark turn. For a
very large key space
+ // that risks memory pressure and exceeding the poll / transaction
timeout. Acceptable for this
+ // initial GlobalWindow GroupByKey (fire once at end of input);
incremental, timer-driven output
+ // via runner-core GroupAlsoByWindow lands with the windowing/timers work.
+ ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
+ KeyValueStore<byte[], byte[]> kvStore = checkInitialized(store);
+ List<byte[]> firedKeys = new ArrayList<>();
+ try (KeyValueIterator<byte[], byte[]> it = kvStore.all()) {
+ while (it.hasNext()) {
+ org.apache.kafka.streams.KeyValue<byte[], byte[]> entry = it.next();
+ Object key = decodeKey(entry.key);
+ List<@Nullable Object> values = decodeBuffer(entry.value);
+ // The pane fires at the end of the global window, so the grouped
element carries the
+ // window's max timestamp (END_OF_GLOBAL_WINDOW). Emitting at
TIMESTAMP_MIN_VALUE (the
+ // default of valueInGlobalWindow) would make the output appear
arbitrarily late and be
+ // dropped downstream once the watermark has advanced.
+ WindowedValue<KV<Object, Iterable<@Nullable Object>>> output =
+ WindowedValues.timestampedValueInGlobalWindow(
+ KV.of(key, (Iterable<@Nullable Object>) values),
+ GlobalWindow.INSTANCE.maxTimestamp());
+ ctx.forward(
+ new Record<byte[], KStreamsPayload<?>>(
+ entry.key, KStreamsPayload.data(output), trigger.timestamp()));
+ firedKeys.add(entry.key);
+ }
+ }
+ for (byte[] key : firedKeys) {
+ kvStore.delete(key);
+ }
+ }
+
+ private void forwardWatermark(Record<byte[], KStreamsPayload<?>> trigger,
long watermarkMillis) {
+ ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
+ // GroupByKey is a single logical source for the next stage; report it as
partition 0 of 1.
+ ctx.forward(
+ new Record<byte[], KStreamsPayload<?>>(
+ trigger.key(), KStreamsPayload.watermark(watermarkMillis, 0, 1),
trigger.timestamp()));
+ }
+
+ private byte[] encodeBuffer(List<@Nullable Object> values) {
+ try {
+ return CoderUtils.encodeToByteArray(bufferCoder, values);
+ } catch (CoderException e) {
+ throw new RuntimeException("Failed to encode GroupByKey value buffer",
e);
+ }
+ }
+
+ private List<@Nullable Object> decodeBuffer(byte[] bytes) {
+ try {
+ List<@Nullable Object> values = new ArrayList<>();
+ for (@Nullable Object value :
CoderUtils.decodeFromByteArray(bufferCoder, bytes)) {
+ values.add(value);
+ }
+ return values;
+ } catch (CoderException e) {
+ throw new RuntimeException("Failed to decode GroupByKey value buffer",
e);
+ }
+ }
+
+ private Object decodeKey(byte[] bytes) {
+ try {
+ return CoderUtils.decodeFromByteArray(keyCoder, bytes);
+ } catch (CoderException e) {
+ throw new RuntimeException("Failed to decode GroupByKey key", e);
+ }
+ }
+
+ private static <T> T checkInitialized(@Nullable T value) {
+ if (value == null) {
+ throw new IllegalStateException("GroupByKeyProcessor used before
init()");
+ }
+ return value;
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
new file mode 100644
index 00000000000..d7c4a309d9c
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
@@ -0,0 +1,130 @@
+/*
+ * 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.kafka.streams.translation;
+
+import static
org.apache.beam.runners.fnexecution.translation.PipelineTranslatorUtils.instantiateCoder;
+
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.WindowedValues;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.state.Stores;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Translates the {@code beam:transform:group_by_key:v1} URN — the runner's
first stateful,
+ * shuffle-bearing transform.
+ *
+ * <p>This is the simplest GroupByKey: GlobalWindow, default trigger, no
allowed lateness (per the
+ * plan agreed with the mentor). Each key's values are buffered in a Kafka
Streams state store and
+ * emitted once as {@code KV<K, Iterable<V>>} when the watermark reaches {@link
+ * org.apache.beam.sdk.transforms.windowing.BoundedWindow#TIMESTAMP_MAX_VALUE}.
+ *
+ * <p>Topology added (the Beam key becomes the Kafka record key so Kafka
Streams shuffles by it):
+ *
+ * <ul>
+ * <li>a {@link ShuffleByKeyProcessor} wired to the input's producer, which
sets the Kafka record
+ * key to the encoded Beam key for data records and passes watermark
reports through;
+ * <li>a {@link Topology#addSink sink} to an internal repartition topic,
with the payload encoded
+ * via {@link KStreamsPayloadSerde} and a {@link
GroupByKeyBroadcastPartitioner} that hashes
+ * data by key and fans watermark reports out to every partition;
+ * <li>a {@link Topology#addSource source} reading the repartition topic
back;
+ * <li>the {@link GroupByKeyProcessor} plus a persistent state store, wired
to the source.
+ * </ul>
+ *
+ * <p>The repartition topic is expected to exist on the broker before the job
starts (same
+ * pre-create assumption as the Impulse bootstrap topic); auto-creation lands
with the AdminClient
+ * wiring in a follow-up.
+ */
+class GroupByKeyTranslator implements PTransformTranslator {
+
+ static final String SHUFFLE_SUFFIX = "-shuffle-by-key";
+ static final String SINK_SUFFIX = "-repartition-sink";
+ static final String SOURCE_SUFFIX = "-repartition-source";
+ static final String STATE_STORE_SUFFIX = "-state";
+ static final String REPARTITION_TOPIC_PREFIX = "__beam_gbk_";
+
+ @Override
+ public void translate(
+ String transformId, RunnerApi.Pipeline pipeline,
KafkaStreamsTranslationContext context) {
+ RunnerApi.PTransform transform =
pipeline.getComponents().getTransformsOrThrow(transformId);
+ String inputPCollectionId =
Iterables.getOnlyElement(transform.getInputsMap().values());
+ String outputPCollectionId =
Iterables.getOnlyElement(transform.getOutputsMap().values());
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ WindowedValues.WindowedValueCoder<KV<Object, Object>> inputCoder =
+ (WindowedValues.WindowedValueCoder)
+ instantiateCoder(inputPCollectionId, pipeline.getComponents());
+ KvCoder<Object, Object> kvCoder = (KvCoder<Object, Object>)
inputCoder.getValueCoder();
+ Coder<Object> keyCoder = kvCoder.getKeyCoder();
+ // User values may be null; the checker tracks that through to the
buffered iterables.
+ @SuppressWarnings("unchecked")
+ Coder<@Nullable Object> valueCoder =
+ (Coder<@Nullable Object>) (Coder<?>) kvCoder.getValueCoder();
+
+ String parentProcessor =
context.getProcessorNameForPCollection(inputPCollectionId);
+
+ String shuffleName = transformId + SHUFFLE_SUFFIX;
+ String sinkName = transformId + SINK_SUFFIX;
+ String sourceName = transformId + SOURCE_SUFFIX;
+ String stateStoreName = transformId + STATE_STORE_SUFFIX;
+ String repartitionTopic = repartitionTopic(transformId);
+
+ KStreamsPayloadSerde<KV<Object, Object>> payloadSerde = new
KStreamsPayloadSerde<>(inputCoder);
+
+ Topology topology = context.getTopology();
+
+ // Re-key data records by the encoded Beam key; pass watermark reports
through.
+ topology.addProcessor(shuffleName, () -> new
ShuffleByKeyProcessor(keyCoder), parentProcessor);
+
+ // Shuffle through the repartition topic: data partitioned by key,
watermark broadcast.
+ topology.addSink(
+ sinkName,
+ repartitionTopic,
+ Serdes.ByteArray().serializer(),
+ payloadSerde.serializer(),
+ new GroupByKeyBroadcastPartitioner<>(),
+ shuffleName);
+ topology.addSource(
+ sourceName,
+ Serdes.ByteArray().deserializer(),
+ payloadSerde.deserializer(),
+ repartitionTopic);
+
+ // Buffer values per key and fire KV<K, Iterable<V>> at the terminal
watermark.
+ topology.addProcessor(
+ transformId,
+ () -> new GroupByKeyProcessor(stateStoreName, keyCoder, valueCoder),
+ sourceName);
+ topology.addStateStore(
+ Stores.keyValueStoreBuilder(
+ Stores.persistentKeyValueStore(stateStoreName),
Serdes.ByteArray(), Serdes.ByteArray()),
+ transformId);
+
+ context.registerPCollectionProducer(outputPCollectionId, transformId);
+ }
+
+ /** The internal repartition topic name for a GroupByKey transform. */
+ static String repartitionTopic(String transformId) {
+ return REPARTITION_TOPIC_PREFIX +
transformId.replaceAll("[^a-zA-Z0-9._-]", "_");
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
index 4e227749e1a..189482ed5f9 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
@@ -51,6 +51,7 @@ public class KafkaStreamsPipelineTranslator {
ImmutableMap.<String, PTransformTranslator>builder()
.put(PTransformTranslation.IMPULSE_TRANSFORM_URN, new
ImpulseTranslator())
.put(PTransformTranslation.REDISTRIBUTE_ARBITRARILY_URN, new
RedistributeTranslator())
+ .put(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN, new
GroupByKeyTranslator())
.put(ExecutableStage.URN, new ExecutableStageTranslator())
.build());
}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
new file mode 100644
index 00000000000..3184d838e09
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
@@ -0,0 +1,86 @@
+/*
+ * 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.kafka.streams.translation;
+
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.CoderException;
+import org.apache.beam.sdk.util.CoderUtils;
+import org.apache.beam.sdk.values.KV;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Re-keys a {@code KV}-valued stream by the Beam key so Kafka Streams
shuffles by it.
+ *
+ * <p>This is not GroupByKey-specific: any transform that needs the values of
a key co-located on
+ * one partition uses it — GroupByKey today, and stateful ParDo later. For a
data record it sets the
+ * Kafka record key to the encoded Beam key (taken from the {@code KV}), so
the downstream
+ * repartition sink co-locates every value of a key. Watermark reports are
forwarded unchanged — the
+ * {@link GroupByKeyBroadcastPartitioner} fans them out to all partitions so
every downstream task
+ * can fire.
+ */
+class ShuffleByKeyProcessor
+ implements Processor<byte[], KStreamsPayload<?>, byte[],
KStreamsPayload<?>> {
+
+ private final Coder<Object> keyCoder;
+ private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
+
+ ShuffleByKeyProcessor(Coder<Object> keyCoder) {
+ this.keyCoder = keyCoder;
+ }
+
+ @Override
+ public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
+ this.context = context;
+ }
+
+ @Override
+ public void process(Record<byte[], KStreamsPayload<?>> record) {
+ ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
+ KStreamsPayload<?> payload = record.value();
+ if (payload.isData()) {
+ Object element = payload.getData().getValue();
+ if (element == null) {
+ throw new IllegalStateException("shuffle data element must not be
null");
+ }
+ Object key = ((KV<?, ?>) element).getKey();
+ if (key == null) {
+ throw new IllegalStateException("shuffle key must not be null");
+ }
+ byte[] encodedKey;
+ try {
+ encodedKey = CoderUtils.encodeToByteArray(keyCoder, key);
+ } catch (CoderException e) {
+ throw new RuntimeException("Failed to encode shuffle key", e);
+ }
+ ctx.forward(record.withKey(encodedKey));
+ } else {
+ // Watermark report: forward as-is; the sink's partitioner broadcasts it
to all partitions.
+ ctx.forward(record);
+ }
+ }
+
+ private static <T> T checkInitialized(@Nullable T value) {
+ if (value == null) {
+ throw new IllegalStateException("ShuffleByKeyProcessor used before
init()");
+ }
+ return value;
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTest.java
new file mode 100644
index 00000000000..0aed80fa858
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTest.java
@@ -0,0 +1,184 @@
+/*
+ * 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.kafka.streams.translation;
+
+import static org.hamcrest.CoreMatchers.hasItems;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
+import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.coders.VarIntCoder;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.options.PortablePipelineOptions;
+import org.apache.beam.sdk.testing.CrashingRunner;
+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.ParDo;
+import org.apache.beam.sdk.util.construction.Environments;
+import org.apache.beam.sdk.util.construction.PTransformTranslation;
+import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
+import org.apache.beam.sdk.util.construction.PipelineTranslation;
+import org.apache.beam.sdk.values.KV;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.apache.kafka.common.serialization.ByteArraySerializer;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.TestInputTopic;
+import org.apache.kafka.streams.TestOutputTopic;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.test.TestRecord;
+import org.junit.Test;
+
+/**
+ * End-to-end test of GroupByKey: {@code Impulse -> emit KVs -> GroupByKey ->
record groups}.
+ *
+ * <p>GroupByKey shuffles through an internal repartition topic. {@link
TopologyTestDriver} does not
+ * loop a low-level sink topic back into its source, so the test drives the
upstream, drains the
+ * repartition topic, and pipes those records back into it — standing in for
the broker round-trip.
+ * The downstream {@code RecordGroupFn} records each emitted group into a
{@link
+ * SharedTestCollector}.
+ */
+public class GroupByKeyTest {
+
+ private static final String JOB_ID = "ks-gbk-test";
+ private static final String APPLICATION_ID = "ks-gbk-test";
+
+ /** Emits a few KVs from the single impulse element so there is something to
group. */
+ private static class EmitKvsFn extends DoFn<byte[], KV<String, Integer>> {
+ @ProcessElement
+ public void processElement(OutputReceiver<KV<String, Integer>> out) {
+ out.output(KV.of("a", 1));
+ out.output(KV.of("a", 2));
+ out.output(KV.of("b", 3));
+ }
+ }
+
+ /** Records each grouped result as {@code "key=[sorted values]"}. */
+ private static class RecordGroupFn extends DoFn<KV<String,
Iterable<Integer>>, Void> {
+ private final SharedTestCollector<String> collector;
+
+ RecordGroupFn(SharedTestCollector<String> collector) {
+ this.collector = collector;
+ }
+
+ @ProcessElement
+ public void processElement(@Element KV<String, Iterable<Integer>> group) {
+ List<Integer> values = new ArrayList<>();
+ group.getValue().forEach(values::add);
+ Collections.sort(values);
+ collector.record(group.getKey() + "=" + values);
+ }
+ }
+
+ @Test
+ public void groupsValuesByKeyAndFiresAtWatermark() throws Exception {
+ try (SharedTestCollector<String> collector = SharedTestCollector.create())
{
+ Pipeline pipeline = Pipeline.create(pipelineOptions());
+ pipeline
+ .apply("impulse", Impulse.create())
+ .apply("emit", ParDo.of(new EmitKvsFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()))
+ .apply("gbk", GroupByKey.create())
+ .apply("record", ParDo.of(new RecordGroupFn(collector)));
+
+ RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
+ KafkaStreamsPipelineOptions options =
+ pipeline.getOptions().as(KafkaStreamsPipelineOptions.class);
+ KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
+ JobInfo jobInfo =
+ JobInfo.create(
+ JOB_ID, options.getJobName(), "",
PipelineOptionsTranslation.toProto(options));
+ KafkaStreamsTranslationContext context =
+ translator.createTranslationContext(jobInfo, options);
+
+ RunnerApi.Pipeline prepared =
translator.prepareForTranslation(pipelineProto);
+ translator.translate(context, prepared);
+ String repartitionTopic =
GroupByKeyTranslator.repartitionTopic(findGroupByKeyId(prepared));
+
+ Topology topology = context.getTopology();
+ try (TopologyTestDriver driver = new TopologyTestDriver(topology,
streamsConfig())) {
+ // Fire the impulse; the upstream stage emits the KVs and the terminal
watermark, which the
+ // re-key processor sends to the repartition sink.
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+
+ // Round-trip the repartition topic: drain what the sink wrote and
feed it back to the
+ // source so the GroupByKey processor buffers the values and fires at
the watermark.
+ TestOutputTopic<byte[], byte[]> repartitionOut =
+ driver.createOutputTopic(
+ repartitionTopic, new ByteArrayDeserializer(), new
ByteArrayDeserializer());
+ TestInputTopic<byte[], byte[]> repartitionIn =
+ driver.createInputTopic(
+ repartitionTopic, new ByteArraySerializer(), new
ByteArraySerializer());
+ for (TestRecord<byte[], byte[]> record :
repartitionOut.readRecordsToList()) {
+ repartitionIn.pipeInput(record);
+ }
+ }
+
+ List<String> groups = collector.recorded();
+ assertThat(groups.size(), is(2));
+ assertThat(groups, hasItems("a=[1, 2]", "b=[3]"));
+ }
+ }
+
+ private static String findGroupByKeyId(RunnerApi.Pipeline pipeline) {
+ return pipeline.getComponents().getTransformsMap().entrySet().stream()
+ .filter(
+ e ->
+ PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN.equals(
+ e.getValue().getSpec().getUrn()))
+ .map(java.util.Map.Entry::getKey)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no GroupByKey transform in the
pipeline"));
+ }
+
+ private static PipelineOptions pipelineOptions() {
+ PipelineOptions options =
+ PipelineOptionsFactory.fromArgs("--applicationId=" +
APPLICATION_ID).create();
+ options.setRunner(CrashingRunner.class);
+
options.as(KafkaStreamsPipelineOptions.class).setApplicationId(APPLICATION_ID);
+ options
+ .as(PortablePipelineOptions.class)
+ .setDefaultEnvironmentType(Environments.ENVIRONMENT_EMBEDDED);
+ return options;
+ }
+
+ private static Properties streamsConfig() {
+ Properties props = new Properties();
+ props.put(StreamsConfig.APPLICATION_ID_CONFIG, APPLICATION_ID);
+ props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
+ props.put(
+ StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ props.put(
+ StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ return props;
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
index 13baa551ebb..8b56915fe26 100644
---
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
@@ -45,18 +45,18 @@ public class KafkaStreamsPipelineTranslatorTest {
KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
KafkaStreamsTranslationContext context = newContext();
+ // A URN the runner does not register a translator for.
+ String unsupportedUrn = "beam:transform:kafka_streams_unsupported_test:v1";
RunnerApi.Pipeline pipeline =
RunnerApi.Pipeline.newBuilder()
- .addRootTransformIds("gbk")
+ .addRootTransformIds("unsupported")
.setComponents(
RunnerApi.Components.newBuilder()
.putTransforms(
- "gbk",
+ "unsupported",
RunnerApi.PTransform.newBuilder()
- .setUniqueName("GroupByKey")
- .setSpec(
- RunnerApi.FunctionSpec.newBuilder()
-
.setUrn(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN))
+ .setUniqueName("Unsupported")
+
.setSpec(RunnerApi.FunctionSpec.newBuilder().setUrn(unsupportedUrn))
.build()))
.build();
@@ -67,8 +67,8 @@ public class KafkaStreamsPipelineTranslatorTest {
UnsupportedOperationException.class, () ->
translator.translate(context, pipeline));
assertThat(ex.getMessage(), containsString("No translator registered for
URN"));
- assertThat(ex.getMessage(),
containsString(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN));
- assertThat(ex.getMessage(), containsString("gbk"));
+ assertThat(ex.getMessage(), containsString(unsupportedUrn));
+ assertThat(ex.getMessage(), containsString("unsupported"));
assertThat(ex.getMessage(), containsString(JOB_ID));
}