KAFKA-4936: Add dynamic routing in Streams#5018
Conversation
| * {@link StreamsConfig stream configuration}. | ||
| * | ||
| * @param name the unique name of the sink | ||
| * @param topicExtractor the mapper to dynamically choose the name of the Kafka topic to which this sink should write per reach record |
| } | ||
|
|
||
| /** | ||
| * Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically. |
There was a problem hiding this comment.
IMHO, "dynamically" need a little bit more explanation. Should we also state, that all those topics must be created by the user manually?
There was a problem hiding this comment.
I agree with "dynamically"... maybe "based on the {@code topicExtractor}"?
About the second point, I think that's addressed in the following line.
| * @param name the unique name of the sink | ||
| * @param topicExtractor the mapper to dynamically choose the name of the Kafka topic to which this sink should write per reach record | ||
| * @param parentNames the name of one or more source or processor nodes whose output records this sink should consume | ||
| * and write to its topic |
| * @param parentNames the name of one or more source or processor nodes whose output records this sink should consume | ||
| * and write to its topic | ||
| * @return itself | ||
| * @throws TopologyException if parent processor is not added yet, or if this processor's name is equal to the parent's name |
There was a problem hiding this comment.
we also throw if name is not unique?
| * @see #addSink(String, String, Serializer, Serializer, StreamPartitioner, String...) | ||
| */ | ||
| public synchronized <K, V> Topology addSink(final String name, | ||
| final TopicNameExtractor<K, V> topicExtractor, |
| * does not exist in the Kafka cluster. | ||
| * | ||
| * @param key the record key | ||
| * @param value the record value payload |
| * @param key the record key | ||
| * @param value the record value payload | ||
| * @param recordContext current context metadata of the record | ||
| * @return the topic name to send to |
There was a problem hiding this comment.
the topic name the record should be sent to ?
| throw new StreamsException("Invalid (negative) timestamp of " + timestamp + " for output record <" + key + ":" + value + ">."); | ||
| } | ||
|
|
||
| final String topic = topicExtractor != null ? topicExtractor.extract(key, value, this.context.recordContext()) : this.topic; |
There was a problem hiding this comment.
I am wondering, if we should always use a TopicNameExtractor. Ie, if a topic is given, we create a TopicNameExtractor that always return the topic name?
There was a problem hiding this comment.
I tend to agree, would be consistent with other approaches like the StateRestoreListener
There was a problem hiding this comment.
I tend to not do that because it would be one extra function call on each record to send to.
There was a problem hiding this comment.
Not sure what is more performant: a function call (that might get inlined via JIT) or evaluating the branch-condition that would also be optimized during runtime quite a bit.... Was just a thought. Not sure if there will be a big difference between both during runtime.
There was a problem hiding this comment.
I'll go ahead and make the change, with some optimizations on runtime window partitioner check.
| stream.to(new TopicNameExtractor<String, String>() { | ||
| @Override | ||
| public String extract(String key, String value, RecordContext recordContext) { | ||
| return recordContext.topic() + "-" + key + "-topic"; |
There was a problem hiding this comment.
I wanted to check that the record context fields and the key value are accessible in this test.
There was a problem hiding this comment.
Should be two tests instead? (Leave it up to you to decide -- don't insist on a change)
| * // to the through call | ||
| * }}} | ||
| * | ||
| * @param extractor the mapper from key value to topic name |
| throw new StreamsException("Invalid (negative) timestamp of " + timestamp + " for output record <" + key + ":" + value + ">."); | ||
| } | ||
|
|
||
| final String topic = topicExtractor != null ? topicExtractor.extract(key, value, this.context.recordContext()) : this.topic; |
There was a problem hiding this comment.
I tend to agree, would be consistent with other approaches like the StateRestoreListener
| // prefix the internal topic name with the application id | ||
| return new SinkNode<>(name, decorateTopic(topic), keySerializer, valSerializer, partitioner); | ||
| if (topic != null) { | ||
| if (internalTopicNames.contains(topic)) { |
There was a problem hiding this comment.
Maybe extract lines 361-363 to a method named something like createSinkNodeWithTopic returning a SinkNode then this method body would just have single if-else statement. But not a big deal could stay as is.
There was a problem hiding this comment.
I think it is okay to keep as is since this function will only be called once so no harm to just inline it.
| * | ||
| * @param name the unique name of the sink | ||
| * @param topicExtractor the mapper to dynamically choose the name of the Kafka topic to which this sink should write per reach record | ||
| * @param topicExtractor the extractor to determine the name of the Kafka topic to which this sink should write for reach record |
There was a problem hiding this comment.
typo reach should be each (Might be somewhere else, too. Please double check.)
| * and dynamically write to topics | ||
| * @return itself | ||
| * @throws TopologyException if parent processor is not added yet, or if this processor's name is equal to the parent's name | ||
| * @throws TopologyException if parent processor is not added yet, or if this processor's name is not unique |
There was a problem hiding this comment.
I think we throw for all three cases.
There was a problem hiding this comment.
this processor's name is equal to the parent's name is subsumed by this processor's name is not unique, right?
There was a problem hiding this comment.
I don't think so. If A connects to itself, it's still a single node:
topology.addSource("source",...);
topology.addProcessor("name", ..., "parent"); // throws because "parent" no added yet
topology.addProcessor("source", ...); // throws because "source" is already used
topology.addProcessor("processor", ..., "processor"); // throws because it connects to itself --- note that internally, we would add "processor" first before setting up the connection (ie, the parent is know then setting up the connection as we just added the parent) --- however, we do have an additional check in the code to throw if people build a loop like this
There was a problem hiding this comment.
Make sense, will add it back.
| * }}} | ||
| * | ||
| * @param extractor the mapper from key value to topic name | ||
| * @param extractor the extractor to determine the name of the Kafka topic to write to for reach record |
|
@mjsax my current solution is to test at runtime, which should work but a bit ugly. I think it is OK for this PR itself. |
|
Agreed -- didn't want to imply to fix it with this PR. But might be worth to fix properly with a follow up KIP. |
Sounds good, I'll create a JIRA. |
bbejeck
left a comment
There was a problem hiding this comment.
Thanks, @guozhangwang, just one minor comment otherwise LGTM.
| public interface TopicNameExtractor<K, V> { | ||
|
|
||
| /** | ||
| * Extracts the topic name to send to. The topic name must be pre-existed, since the Kafka Streams library will not |
There was a problem hiding this comment.
nit: The topic name must be pre-existed -> The returned topic name must already exist
…namic-routing-on-sink
|
@mjsax while working on the window stream partitioner along with merging single topic to topic extractor, I realized the root cause is because in So I'm modifying this and include it as part of the public API changes in the KIP wiki. Will ask for recasting the vote. The previous ugly solution I had has a performance issue, as it checks |
|
@guozhangwang Sounds good to me! |
…namic-routing-on-sink
| if (topicNameExtractor instanceof StaticTopicNameExtractor) | ||
| return ((StaticTopicNameExtractor) topicNameExtractor).topicName; | ||
| else | ||
| return topicNameExtractor.getClass().getName(); |
There was a problem hiding this comment.
Why do we return the class name here? Maybe return null indicating unknown?
There was a problem hiding this comment.
Since this is for string typed printing, I felt printing the class name is more informative than returning a null. WDYT?
There was a problem hiding this comment.
If this method is used only for informational purposes, maybe we should just call toString on the topicNameExtractor and make sure that the StaticTopicNameExtractor includes the topic name in its output. Better yet, we could just change the method to return the topicNameExtractor itself, since it really doesn't have a topic anymore.
If it's used for any other not-strictly-informational purpose, it seems risky to return some other string, claiming it's the "topic". It seems like it could become a bug in the future...
| this.topicName = topicName; | ||
| } | ||
|
|
||
| public String extract(K key, V value, RecordContext recordContext) { |
|
@mjsax updated on comments again. |
|
One last thing: we need to update the docs accordingly. |
Yup, that's my plan once the KIP is accepted, and the public changes are final. |
|
retest this please |
…namic-routing-on-sink
add record context
| @@ -1,81 +0,0 @@ | |||
| /* | |||
| * Licensed to the Apache Software Foundation (ASF) under one or more | |||
There was a problem hiding this comment.
This is removed as its functionality is completely replaced with ProcessorRecordContext now.
| Forwarding based on child index is not supported in the new API any longer. | ||
| </p> | ||
| <p> | ||
| We have added support to allow dynamically routing records to Kafka topics. More specifically, in both the lower-level <code>Topology#addSink</code> and higher-level <code>KStream#to</code> APIs, we have added variants that |
There was a problem hiding this comment.
We have added support to allow routing records dynamically to Kafka topics. ?
| </p> | ||
| <p> | ||
| We have added support to allow dynamically routing records to Kafka topics. More specifically, in both the lower-level <code>Topology#addSink</code> and higher-level <code>KStream#to</code> APIs, we have added variants that | ||
| takes a <code>TopicNameExtractor</code> instance instead of a specific <code>String</code> topic name, such that for each received record from the upstream processor, the library will dynamically determine which Kafka topic to write to |
There was a problem hiding this comment.
specific topic name (i.e., a <code>String</code>) ?
| <p> | ||
| We have added support to allow dynamically routing records to Kafka topics. More specifically, in both the lower-level <code>Topology#addSink</code> and higher-level <code>KStream#to</code> APIs, we have added variants that | ||
| takes a <code>TopicNameExtractor</code> instance instead of a specific <code>String</code> topic name, such that for each received record from the upstream processor, the library will dynamically determine which Kafka topic to write to | ||
| based on the record's key and value, as well as record context. Note that all the Kafka topics that may possibly to write to are still considered as user topics and hence required to be pre-created. In addition to that, we have modified the |
| } | ||
|
|
||
| @Override | ||
| public boolean equals(final Object o) { |
There was a problem hiding this comment.
Why do we need equals() and hashCode() now?
There was a problem hiding this comment.
To make bugWarnings happy
There was a problem hiding this comment.
Why was bugWarnings unhappy? Is this class used in an equals comparison somewhere? Or maybe a collection?
There was a problem hiding this comment.
I think bugwarning requires any objects that is used as key in a map to override these two functions.
| protected final long offset; | ||
| protected final String topic; | ||
| protected final int partition; | ||
| protected final Headers headers; |
There was a problem hiding this comment.
Am I right in thinking that these are only exposed so that LRUCacheEntry can use them in equals/hashcode? If so, can it instead invoke the getters and keep these private?
| </p> | ||
| <p> | ||
| We have added support to allow routing records dynamically to Kafka topics. More specifically, in both the lower-level <code>Topology#addSink</code> and higher-level <code>KStream#to</code> APIs, we have added variants that | ||
| takes a <code>TopicNameExtractor</code> instance instead of a specific <code>String</code> typed topic name, such that for each received record from the upstream processor, the library will dynamically determine which Kafka topic to write to |
| } | ||
|
|
||
| /** | ||
| * Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically. |
There was a problem hiding this comment.
I agree with "dynamically"... maybe "based on the {@code topicExtractor}"?
About the second point, I think that's addressed in the following line.
| } | ||
|
|
||
| /** | ||
| * Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically, |
| } | ||
|
|
||
| /** | ||
| * Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically. |
| void to(final TopicNameExtractor<K, V> topicExtractor); | ||
|
|
||
| /** | ||
| * Dynamically materialize this stream to topics using default serializers specified in the config. |
There was a problem hiding this comment.
This one doesn't use the default serializers, but instead uses produced, right?
| if (topicNameExtractor instanceof StaticTopicNameExtractor) | ||
| return ((StaticTopicNameExtractor) topicNameExtractor).topicName; | ||
| else | ||
| return topicNameExtractor.getClass().getName(); |
There was a problem hiding this comment.
If this method is used only for informational purposes, maybe we should just call toString on the topicNameExtractor and make sure that the StaticTopicNameExtractor includes the topic name in its output. Better yet, we could just change the method to return the topicNameExtractor itself, since it really doesn't have a topic anymore.
If it's used for any other not-strictly-informational purpose, it seems risky to return some other string, claiming it's the "topic". It seems like it could become a bug in the future...
| stream.to(new TopicNameExtractor<String, String>() { | ||
| @Override | ||
| public String extract(String key, String value, RecordContext recordContext) { | ||
| return recordContext.topic() + "-" + key + "-" + value.substring(0, 1); |
There was a problem hiding this comment.
we're in Java8 now... I think you can do: (key,value,context) -> { ... }
There was a problem hiding this comment.
Will that make this code easier or harder to read... who can say?
| hostToPartitions.put(hostThree, Collections.singleton(topic3P0)); | ||
|
|
||
| partitionInfos = Arrays.asList( | ||
| List<PartitionInfo> partitionInfos = Arrays.asList( |
bbejeck
left a comment
There was a problem hiding this comment.
Just added one minor comment, otherwise LGTM with latest changes.
| } | ||
|
|
||
| /** | ||
| * Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically. |
|
I've addressed the meta comment on |
implements KIP-303 Reviewers: Bill Bejeck <bill@confluent.io>, John Roesler <john@confluent.io>, Matthias J. Sax <matthias@confluent.io>
Add the proposed APIs as described in https://cwiki.apache.org/confluence/display/KAFKA/KIP-303%3A+Add+Dynamic+Routing+in+Streams+Sink.
Modify StreamPartitioner#partition to include topic name along with this change, since now it is less reasonable to ignore the topic name since it may not be pre-known.
Update TopologyDescription#Sink implementation: when the topic name is static, print the topic name, otherwise print the dynamic topic name extractor class name.
Committer Checklist (excluded from commit message)