Skip to content

KAFKA-4936: Add dynamic routing in Streams#5018

Merged
mjsax merged 23 commits into
apache:trunkfrom
guozhangwang:K4936-dynamic-routing-on-sink
May 30, 2018
Merged

KAFKA-4936: Add dynamic routing in Streams#5018
mjsax merged 23 commits into
apache:trunkfrom
guozhangwang:K4936-dynamic-routing-on-sink

Conversation

@guozhangwang

@guozhangwang guozhangwang commented May 15, 2018

Copy link
Copy Markdown
Contributor
  1. Add the proposed APIs as described in https://cwiki.apache.org/confluence/display/KAFKA/KIP-303%3A+Add+Dynamic+Routing+in+Streams+Sink.

  2. 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.

  3. 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)

  • Verify design and implementation
  • Verify test coverage and CI build status
  • Verify documentation (including upgrade notes)

@guozhangwang guozhangwang changed the title KAFKA-4936: Add dynamic routing in Streams [DO NOT MERGE] KAFKA-4936: Add dynamic routing in Streams May 17, 2018
@guozhangwang
guozhangwang requested review from dguy and mjsax May 17, 2018 18:48
@mjsax mjsax added the streams label May 17, 2018
@mjsax

mjsax commented May 17, 2018

Copy link
Copy Markdown
Member

\cc @bbejeck @vvcephei

@mjsax mjsax left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR!

* {@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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: per reach record

}

/**
* Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, "dynamically" need a little bit more explanation. Should we also state, that all those topics must be created by the user manually?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with "dynamically"... maybe "based on the {@code topicExtractor}"?

About the second point, I think that's addressed in the following line.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to "based on the {@code topicExtractor}"

* @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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its topic -> need rephrasing ?

* @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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: fix indention

* does not exist in the Kafka cluster.
*
* @param key the record key
* @param value the record value payload

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to agree, would be consistent with other approaches like the StateRestoreListener

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to not do that because it would be one extra function call on each record to send to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use the key itself?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to check that the record context fields and the key value are accessible in this test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be updated?

@bbejeck bbejeck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a couple of comments

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we throw for all three cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this processor's name is equal to the parent's name is subsumed by this processor's name is not unique, right?

@mjsax mjsax May 20, 2018

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reach -> each

@guozhangwang

Copy link
Copy Markdown
Contributor Author

@mjsax my current solution is to test

if (partitioner instanceof WindowedStreamPartitioner && ((WindowedStreamPartitioner) partitioner).topic() == null) {
                    partition = ((WindowedStreamPartitioner) partitioner).partition(topic, (Windowed) key, value, partitions.size());

at runtime, which should work but a bit ugly. I think it is OK for this PR itself.

@mjsax

mjsax commented May 20, 2018

Copy link
Copy Markdown
Member

Agreed -- didn't want to imply to fix it with this PR. But might be worth to fix properly with a follow up KIP.

@guozhangwang

Copy link
Copy Markdown
Contributor Author

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 bbejeck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The topic name must be pre-existed -> The returned topic name must already exist

@guozhangwang

guozhangwang commented May 21, 2018

Copy link
Copy Markdown
Contributor Author

@mjsax while working on the window stream partitioner along with merging single topic to topic extractor, I realized the root cause is because in StreamPartitioner interface we do not pass in the topic name, which makes less sense while we are adding dynamic routing to sink node since the topic name would not be able to always pre-known.

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 instanceof and try to do the casting on each record call; note that during the build phase as we 1) decorate the internal topic name, 2) generate sink nodes, generate sink descriptions 3) etc, it is a one-time thing so we do not need to worry.

@mjsax

mjsax commented May 21, 2018

Copy link
Copy Markdown
Member

@guozhangwang Sounds good to me!

@guozhangwang

Copy link
Copy Markdown
Contributor Author

@mjsax @bbejeck updated PR for reviews again. Also updated the description for the core changes.

if (topicNameExtractor instanceof StaticTopicNameExtractor)
return ((StaticTopicNameExtractor) topicNameExtractor).topicName;
else
return topicNameExtractor.getClass().getName();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we return the class name here? Maybe return null indicating unknown?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is for string typed printing, I felt printing the class name is more informative than returning a null. WDYT?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add final

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack.

@guozhangwang

Copy link
Copy Markdown
Contributor Author

@mjsax updated on comments again.

@mjsax

mjsax commented May 22, 2018

Copy link
Copy Markdown
Member

One last thing: we need to update the docs accordingly.

@guozhangwang

Copy link
Copy Markdown
Contributor Author

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.

@bbejeck

bbejeck commented May 22, 2018

Copy link
Copy Markdown
Member

retest this please

@bbejeck bbejeck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updates LGTM

@guozhangwang

Copy link
Copy Markdown
Contributor Author

@mjsax @bbejeck @vvcephei added docs for KIP-303.

@@ -1,81 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is removed as its functionality is completely replaced with ProcessorRecordContext now.

Comment thread docs/streams/upgrade-guide.html Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have added support to allow routing records dynamically to Kafka topics. ?

Comment thread docs/streams/upgrade-guide.html Outdated
</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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

specific topic name (i.e., a <code>String</code>) ?

Comment thread docs/streams/upgrade-guide.html Outdated
<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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that may possibly be used are

}

@Override
public boolean equals(final Object o) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need equals() and hashCode() now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make bugWarnings happy

@vvcephei vvcephei May 24, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was bugWarnings unhappy? Is this class used in an equals comparison somewhere? Or maybe a collection?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread docs/streams/upgrade-guide.html Outdated
</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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

takes -> take

}

/**
* Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above.

}

/**
* Add a new sink that forwards records from upstream parent processor and/or source nodes to Kafka topics dynamically.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

void to(final TopicNameExtractor<K, V> topicExtractor);

/**
* Dynamically materialize this stream to topics using default serializers specified in the config.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're in Java8 now... I think you can do: (key,value,context) -> { ... }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final?

@bbejeck bbejeck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to "based on the {@code topicExtractor}"

@guozhangwang

Copy link
Copy Markdown
Contributor Author

I've addressed the meta comment on SinkNode#topicName to make its retruned value nullable.

@mjsax mjsax left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Waiting for either @bbejeck or @vvcephei to approve before merging.

@mjsax
mjsax merged commit f33e9a3 into apache:trunk May 30, 2018
ying-zheng pushed a commit to ying-zheng/kafka that referenced this pull request Jul 6, 2018
implements KIP-303

Reviewers: Bill Bejeck <bill@confluent.io>, John Roesler <john@confluent.io>, Matthias J. Sax <matthias@confluent.io>
@guozhangwang
guozhangwang deleted the K4936-dynamic-routing-on-sink branch April 24, 2020 23:57
@mjsax mjsax added the kip Requires or implements a KIP label Jun 13, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kip Requires or implements a KIP streams

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants