-
Notifications
You must be signed in to change notification settings - Fork 4.6k
[GSoC 2026] Kafka Streams runner: run on a real broker, correctly across partitions #39546
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/18479-kafka-streams-runner-skeleton
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| /* | ||
| * 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; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Collection; | ||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Properties; | ||
| import java.util.Set; | ||
| import java.util.concurrent.ExecutionException; | ||
| import org.apache.kafka.clients.admin.Admin; | ||
| import org.apache.kafka.clients.admin.AdminClientConfig; | ||
| import org.apache.kafka.clients.admin.NewTopic; | ||
| import org.apache.kafka.common.errors.TopicExistsException; | ||
| import org.apache.kafka.streams.Topology; | ||
| import org.apache.kafka.streams.TopologyDescription; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| /** | ||
| * Creates the topics a translated pipeline needs before the Kafka Streams application starts. | ||
| * | ||
| * <p>The runner shuffles data through topics it names itself: a bootstrap topic per Impulse and per | ||
| * primitive Read, and a repartition topic per GroupByKey. Kafka Streams does create the internal | ||
| * topics it manages on its own, but these are declared with explicit names through {@code | ||
| * addSource} and {@code addSink}, so to Kafka Streams they are ordinary user topics — it will not | ||
| * create them, and refuses to start with {@code MissingSourceTopicException} if a source topic is | ||
| * absent. Relying on the broker's {@code auto.create.topics.enable} is not an option either: it is | ||
| * off on many clusters, and a topic auto-created on first fetch gets the broker's default partition | ||
| * count rather than the pipeline's. | ||
| * | ||
| * <p>Only topics carrying one of the runner's own prefixes are created. Any other topic in the | ||
| * topology belongs to the user (a source or sink they named), and creating those implicitly would | ||
| * hide a misconfiguration behind an empty topic. | ||
| */ | ||
| class KafkaStreamsTopicManager { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm thinking if this should be implicit, or if we should add a life-cycle management: # just create topics
$ java -cp ... my.class --runner=KafkaStreams [... other opts ...] --create-topics
# run
$ java -cp ... my.class --runner=KafkaStreams [... other opts ...]
# delete runner-created topics
$ java -cp ... my.class --runner=KafkaStreams [... other opts ...] --delete-topicsBut this is definitely out of scope of this PR.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filed for later as you suggested: #39566, with your create/run/delete sketch. Agreed it's out of scope here. |
||
|
|
||
| private static final Logger LOG = LoggerFactory.getLogger(KafkaStreamsTopicManager.class); | ||
|
|
||
| /** | ||
| * Prefixes of the bootstrap topics, which must have exactly one partition. | ||
| * | ||
| * <p>An Impulse or a primitive Read emits its elements once per task, gated by a state store that | ||
| * is itself per task. Kafka Streams creates one task per partition of the source topic, so a | ||
| * bootstrap topic with several partitions would make the same Impulse fire once per partition and | ||
| * the same source be read once per partition. | ||
| */ | ||
| private static final List<String> SINGLE_PARTITION_TOPIC_PREFIXES = | ||
| java.util.Arrays.asList("__beam_impulse_", "__beam_read_"); | ||
|
|
||
| /** | ||
| * Prefixes of the topics whose partition count sets the pipeline's parallelism — the repartition | ||
| * topic a GroupByKey shuffles through. | ||
| */ | ||
| private static final List<String> PARTITIONED_TOPIC_PREFIXES = | ||
| java.util.Arrays.asList("__beam_gbk_"); | ||
|
|
||
| private KafkaStreamsTopicManager() {} | ||
|
|
||
| /** | ||
| * Creates any runner-owned topic in {@code topology} that does not exist yet. | ||
| * | ||
| * <p>Safe to run concurrently with another instance of the same job: a topic that appears between | ||
| * the existence check and the create request surfaces as {@link TopicExistsException}, which is | ||
| * treated as success. | ||
| */ | ||
| static void createMissingTopics(Topology topology, KafkaStreamsPipelineOptions options) { | ||
| Set<String> runnerTopics = runnerOwnedTopics(topology); | ||
| if (runnerTopics.isEmpty()) { | ||
| return; | ||
| } | ||
| Properties adminConfig = new Properties(); | ||
| adminConfig.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, options.getBootstrapServers()); | ||
| try (Admin admin = Admin.create(adminConfig)) { | ||
| Set<String> existing = admin.listTopics().names().get(); | ||
| List<NewTopic> toCreate = new ArrayList<>(); | ||
| for (String topic : runnerTopics) { | ||
| if (!existing.contains(topic)) { | ||
| toCreate.add( | ||
| new NewTopic( | ||
| topic, partitionsFor(topic, options), options.getTopicReplicationFactor())); | ||
| } | ||
| } | ||
| if (toCreate.isEmpty()) { | ||
| return; | ||
| } | ||
| LOG.info("Creating {} runner-owned topic(s): {}", toCreate.size(), toCreate); | ||
| createAll(admin, toCreate); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new RuntimeException("Interrupted while creating the pipeline's Kafka topics", e); | ||
| } catch (ExecutionException e) { | ||
| throw new RuntimeException("Failed to create the pipeline's Kafka topics", e); | ||
| } | ||
| } | ||
|
|
||
| private static void createAll(Admin admin, Collection<NewTopic> topics) | ||
| throws InterruptedException, ExecutionException { | ||
| try { | ||
| admin.createTopics(topics).all().get(); | ||
| } catch (ExecutionException e) { | ||
| // Another instance of the same application may have created them first, which is fine. | ||
| if (!(e.getCause() instanceof TopicExistsException)) { | ||
| throw e; | ||
| } | ||
| LOG.debug("Some topics already existed; another instance created them first", e); | ||
| } | ||
| } | ||
|
|
||
| /** The topics in the topology that the runner named, and so is responsible for creating. */ | ||
| private static Set<String> runnerOwnedTopics(Topology topology) { | ||
| Set<String> topics = new HashSet<>(); | ||
| for (TopologyDescription.Subtopology subtopology : topology.describe().subtopologies()) { | ||
| for (TopologyDescription.Node node : subtopology.nodes()) { | ||
| if (node instanceof TopologyDescription.Source) { | ||
| Set<String> sourceTopics = ((TopologyDescription.Source) node).topicSet(); | ||
| if (sourceTopics != null) { | ||
| topics.addAll(sourceTopics); | ||
| } | ||
| } else if (node instanceof TopologyDescription.Sink) { | ||
| String topic = ((TopologyDescription.Sink) node).topic(); | ||
| if (topic != null) { | ||
| topics.add(topic); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| topics.removeIf(topic -> !isRunnerOwned(topic)); | ||
| return topics; | ||
| } | ||
|
|
||
| /** | ||
| * The partition count a runner-owned topic is created with: one for a bootstrap topic, and the | ||
| * configured parallelism for a shuffle topic. | ||
| */ | ||
| private static int partitionsFor(String topic, KafkaStreamsPipelineOptions options) { | ||
| return hasAnyPrefix(topic, SINGLE_PARTITION_TOPIC_PREFIXES) | ||
| ? 1 | ||
| : options.getInternalParallelism(); | ||
| } | ||
|
|
||
| private static boolean isRunnerOwned(String topic) { | ||
| return hasAnyPrefix(topic, SINGLE_PARTITION_TOPIC_PREFIXES) | ||
| || hasAnyPrefix(topic, PARTITIONED_TOPIC_PREFIXES); | ||
| } | ||
|
|
||
| private static boolean hasAnyPrefix(String topic, List<String> prefixes) { | ||
| for (String prefix : prefixes) { | ||
| if (topic.startsWith(prefix)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,9 @@ public void translate( | |
| Set<String> seenInputs = new HashSet<>(); | ||
| List<String> parentProcessors = new ArrayList<>(); | ||
| Set<String> upstreamTransformIds = new HashSet<>(); | ||
| // Kafka Streams puts a processor and the parents it is wired to in one subtopology, so the | ||
| // inputs are co-partitioned and this Flatten runs at their partition count. | ||
| int partitionCount = 1; | ||
| for (String inputPCollectionId : transform.getInputsMap().values()) { | ||
| if (!seenInputs.add(inputPCollectionId)) { | ||
| throw new UnsupportedOperationException( | ||
|
|
@@ -70,6 +73,7 @@ public void translate( | |
| String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId); | ||
| parentProcessors.add(parentProcessor); | ||
| upstreamTransformIds.add(parentProcessor); | ||
| partitionCount = Math.max(partitionCount, context.getPartitionCount(inputPCollectionId)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It could, and that was a gap. It returns 1 for anything unregistered, and otherwise whatever --internalParallelism was set to, which nothing validated. Worse than a bad topic: it's also the number of watermark reports a shuffle's consumer waits for, so a value below 1 would have left the consumer waiting forever rather than failing. The runner now rejects it before translating, and I documented the invariant where the count is read. |
||
| } | ||
|
|
||
| topology.addProcessor( | ||
|
|
@@ -78,5 +82,6 @@ public void translate( | |
| parentProcessors.toArray(new String[0])); | ||
|
|
||
| context.registerPCollectionProducer(outputPCollectionId, transformId); | ||
| context.registerPCollectionPartitionCount(outputPCollectionId, partitionCount); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should probably enable a complete set of topic-level configurations here. We can postpone it, but it would be good to create an issue. We might pass a JSON-serialized dictionary and/or publish the most-common options as separate methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, filed: #39565. Noted both ideas there — a JSON-serialized dictionary and/or the most common ones as separate options.