-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-18682][SS] Batch Source for Kafka #16686
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
Changes from 19 commits
d371758
b6c3055
4c81812
ab02a4c
e6b57ed
ff94ed8
f8fd34c
41271e2
74d96fc
d31fc81
1db1649
3b0d48b
a5b0269
c08c01f
79d335e
b597cf1
2487a72
789d3af
5b48fc6
5776009
aef89bc
4e56f8c
3bc7c4c
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 |
|---|---|---|
|
|
@@ -57,6 +57,20 @@ private[kafka010] case class CachedKafkaConsumer private( | |
| c | ||
| } | ||
|
|
||
| case class AvailableOffsetRange(earliest: Long, latest: Long) | ||
|
|
||
| /** | ||
| * Return the available offset range of the current partition. It's a pair of the earliest offset | ||
| * and the latest offset. | ||
| */ | ||
| def getAvailableOffsetRange(): AvailableOffsetRange = { | ||
| consumer.seekToBeginning(Set(topicPartition).asJava) | ||
| val earliestOffset = consumer.position(topicPartition) | ||
| consumer.seekToEnd(Set(topicPartition).asJava) | ||
| val latestOffset = consumer.position(topicPartition) | ||
| AvailableOffsetRange(earliestOffset, latestOffset) | ||
| } | ||
|
|
||
| /** | ||
| * Get the record for the given offset if available. Otherwise it will either throw error | ||
| * (if failOnDataLoss = true), or return the next available offset within [offset, untilOffset), | ||
|
|
@@ -107,9 +121,9 @@ private[kafka010] case class CachedKafkaConsumer private( | |
| * `UNKNOWN_OFFSET`. | ||
| */ | ||
| private def getEarliestAvailableOffsetBetween(offset: Long, untilOffset: Long): Long = { | ||
| val (earliestOffset, latestOffset) = getAvailableOffsetRange() | ||
| logWarning(s"Some data may be lost. Recovering from the earliest offset: $earliestOffset") | ||
| if (offset >= latestOffset || earliestOffset >= untilOffset) { | ||
| val range = getAvailableOffsetRange() | ||
| logWarning(s"Some data may be lost. Recovering from the earliest offset: ${range.earliest}") | ||
| if (offset >= range.latest || range.earliest >= untilOffset) { | ||
| // [offset, untilOffset) and [earliestOffset, latestOffset) have no overlap, | ||
| // either | ||
| // -------------------------------------------------------- | ||
|
|
@@ -124,13 +138,13 @@ private[kafka010] case class CachedKafkaConsumer private( | |
| // offset untilOffset earliestOffset latestOffset | ||
| val warningMessage = | ||
| s""" | ||
| |The current available offset range is [$earliestOffset, $latestOffset). | ||
| |The current available offset range is [${range.earliest}, ${range.latest}). | ||
| | Offset ${offset} is out of range, and records in [$offset, $untilOffset) will be | ||
| | skipped ${additionalMessage(failOnDataLoss = false)} | ||
| """.stripMargin | ||
| logWarning(warningMessage) | ||
| UNKNOWN_OFFSET | ||
| } else if (offset >= earliestOffset) { | ||
| } else if (offset >= range.earliest) { | ||
| // ----------------------------------------------------------------------------- | ||
| // ^ ^ ^ ^ | ||
| // | | | | | ||
|
|
@@ -149,12 +163,12 @@ private[kafka010] case class CachedKafkaConsumer private( | |
| // offset earliestOffset min(untilOffset,latestOffset) max(untilOffset, latestOffset) | ||
| val warningMessage = | ||
| s""" | ||
| |The current available offset range is [$earliestOffset, $latestOffset). | ||
| | Offset ${offset} is out of range, and records in [$offset, $earliestOffset) will be | ||
| |The current available offset range is [${range.earliest}, ${range.latest}). | ||
|
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. nit: same as above. |
||
| | Offset ${offset} is out of range, and records in [$offset, ${range.earliest}) will be | ||
| | skipped ${additionalMessage(failOnDataLoss = false)} | ||
| """.stripMargin | ||
| logWarning(warningMessage) | ||
| earliestOffset | ||
| range.earliest | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -183,8 +197,8 @@ private[kafka010] case class CachedKafkaConsumer private( | |
| // - `offset` is out of range so that Kafka returns nothing. Just throw | ||
| // `OffsetOutOfRangeException` to let the caller handle it. | ||
| // - Cannot fetch any data before timeout. TimeoutException will be thrown. | ||
| val (earliestOffset, latestOffset) = getAvailableOffsetRange() | ||
| if (offset < earliestOffset || offset >= latestOffset) { | ||
| val range = getAvailableOffsetRange() | ||
| if (offset < range.earliest || offset >= range.latest) { | ||
| throw new OffsetOutOfRangeException( | ||
| Map(topicPartition -> java.lang.Long.valueOf(offset)).asJava) | ||
| } else { | ||
|
|
@@ -284,18 +298,6 @@ private[kafka010] case class CachedKafkaConsumer private( | |
| logDebug(s"Polled $groupId ${p.partitions()} ${r.size}") | ||
| fetchedData = r.iterator | ||
| } | ||
|
|
||
| /** | ||
| * Return the available offset range of the current partition. It's a pair of the earliest offset | ||
| * and the latest offset. | ||
| */ | ||
| private def getAvailableOffsetRange(): (Long, Long) = { | ||
| consumer.seekToBeginning(Set(topicPartition).asJava) | ||
| val earliestOffset = consumer.position(topicPartition) | ||
| consumer.seekToEnd(Set(topicPartition).asJava) | ||
| val latestOffset = consumer.position(topicPartition) | ||
| (earliestOffset, latestOffset) | ||
| } | ||
| } | ||
|
|
||
| private[kafka010] object CachedKafkaConsumer extends Logging { | ||
|
|
@@ -334,14 +336,15 @@ private[kafka010] object CachedKafkaConsumer extends Logging { | |
| def getOrCreate( | ||
| topic: String, | ||
| partition: Int, | ||
| kafkaParams: ju.Map[String, Object]): CachedKafkaConsumer = synchronized { | ||
| kafkaParams: ju.Map[String, Object], | ||
| reuseExistingIfPresent: Boolean): CachedKafkaConsumer = synchronized { | ||
| val groupId = kafkaParams.get(ConsumerConfig.GROUP_ID_CONFIG).asInstanceOf[String] | ||
| val topicPartition = new TopicPartition(topic, partition) | ||
| val key = CacheKey(groupId, topicPartition) | ||
|
|
||
| // If this is reattempt at running the task, then invalidate cache and start with | ||
| // a new consumer | ||
| if (TaskContext.get != null && TaskContext.get.attemptNumber > 1) { | ||
| if (!reuseExistingIfPresent || TaskContext.get != null && TaskContext.get.attemptNumber > 1) { | ||
| val removedConsumer = cache.remove(key) | ||
| if (removedConsumer != null) { | ||
| removedConsumer.close() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /* | ||
| * 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.spark.sql.kafka010 | ||
|
|
||
| import java.{util => ju} | ||
|
|
||
| import scala.collection.JavaConverters._ | ||
|
|
||
| import org.apache.kafka.clients.consumer.{Consumer, KafkaConsumer} | ||
| import org.apache.kafka.clients.consumer.internals.NoOpConsumerRebalanceListener | ||
| import org.apache.kafka.common.TopicPartition | ||
|
|
||
| /** | ||
| * Subscribe allows you to subscribe to a fixed collection of topics. | ||
| * SubscribePattern allows you to use a regex to specify topics of interest. | ||
| * Note that unlike the 0.8 integration, * using Subscribe or SubscribePattern | ||
| * should respond to adding partitions during a running stream. | ||
| * Finally, Assign allows you to specify a fixed collection of partitions. | ||
| * All three strategies have overloaded constructors that allow you to specify | ||
| * the starting offset for a particular partition. | ||
| */ | ||
| sealed trait ConsumerStrategy { | ||
| def createConsumer(kafkaParams: ju.Map[String, Object]): Consumer[Array[Byte], Array[Byte]] | ||
| } | ||
|
|
||
| /** | ||
|
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. nit: short docs can be put in a single line |
||
| * Specify a fixed collection of partitions. | ||
| */ | ||
| case class AssignStrategy(partitions: Array[TopicPartition]) extends ConsumerStrategy { | ||
| override def createConsumer( | ||
| kafkaParams: ju.Map[String, Object]): Consumer[Array[Byte], Array[Byte]] = { | ||
| val consumer = new KafkaConsumer[Array[Byte], Array[Byte]](kafkaParams) | ||
| consumer.assign(ju.Arrays.asList(partitions: _*)) | ||
| consumer | ||
| } | ||
|
|
||
| override def toString: String = s"Assign[${partitions.mkString(", ")}]" | ||
| } | ||
|
|
||
| /** | ||
| * Subscribe to a fixed collection of topics. | ||
| */ | ||
| case class SubscribeStrategy(topics: Seq[String]) extends ConsumerStrategy { | ||
| override def createConsumer( | ||
| kafkaParams: ju.Map[String, Object]): Consumer[Array[Byte], Array[Byte]] = { | ||
| val consumer = new KafkaConsumer[Array[Byte], Array[Byte]](kafkaParams) | ||
| consumer.subscribe(topics.asJava) | ||
| consumer | ||
| } | ||
|
|
||
| override def toString: String = s"Subscribe[${topics.mkString(", ")}]" | ||
| } | ||
|
|
||
| /** | ||
| * Use a regex to specify topics of interest. | ||
| */ | ||
| case class SubscribePatternStrategy(topicPattern: String) extends ConsumerStrategy { | ||
| override def createConsumer( | ||
| kafkaParams: ju.Map[String, Object]): Consumer[Array[Byte], Array[Byte]] = { | ||
| val consumer = new KafkaConsumer[Array[Byte], Array[Byte]](kafkaParams) | ||
| consumer.subscribe( | ||
| ju.regex.Pattern.compile(topicPattern), | ||
| new NoOpConsumerRebalanceListener()) | ||
| consumer | ||
| } | ||
|
|
||
| override def toString: String = s"SubscribePattern[$topicPattern]" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,14 +19,31 @@ package org.apache.spark.sql.kafka010 | |
|
|
||
| import org.apache.kafka.common.TopicPartition | ||
|
|
||
| /* | ||
| * Values that can be specified for config startingOffsets | ||
| /** | ||
| * Values that can be specified to configure starting, | ||
| * ending, and specific offsets. | ||
| */ | ||
| private[kafka010] sealed trait StartingOffsets | ||
| private[kafka010] sealed trait KafkaOffsets | ||
|
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. The more I think, I feel that its weird to name this generic "KafkaOffsets". Let's brainstorm on this.
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. How about
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. I went with KafkaOffsetRangeLimit |
||
|
|
||
| private[kafka010] case object EarliestOffsets extends StartingOffsets | ||
| /** | ||
| * Bind to the earliest offsets in Kafka | ||
|
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. nit: better docs. this is object, not a method. say what the object represents. "Bind to earliest offsets..." is like docs for a method |
||
| */ | ||
| private[kafka010] case object EarliestOffsets extends KafkaOffsets | ||
|
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. docs. |
||
|
|
||
| private[kafka010] case object LatestOffsets extends StartingOffsets | ||
| /** | ||
| * Bind to the latest offsets in Kafka | ||
| */ | ||
| private[kafka010] case object LatestOffsets extends KafkaOffsets | ||
|
|
||
| /** | ||
| * Bind to the specific offsets. A offset == -1 binds to the latest | ||
| * offset, and offset == -2 binds to the earliest offset. | ||
| */ | ||
| private[kafka010] case class SpecificOffsets( | ||
| partitionOffsets: Map[TopicPartition, Long]) extends StartingOffsets | ||
| partitionOffsets: Map[TopicPartition, Long]) extends KafkaOffsets | ||
|
|
||
| private[kafka010] object KafkaOffsets { | ||
| // Used to denote unbounded offset positions | ||
|
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. nit: Used to represent unresolved offset limits as longs |
||
| val LATEST = -1L | ||
| val EARLIEST = -2L | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /* | ||
| * 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.spark.sql.kafka010 | ||
|
|
||
| import java.{util => ju} | ||
|
|
||
| import org.apache.kafka.common.TopicPartition | ||
|
|
||
| import org.apache.spark.internal.Logging | ||
| import org.apache.spark.rdd.RDD | ||
| import org.apache.spark.sql.{Row, SQLContext} | ||
| import org.apache.spark.sql.catalyst.InternalRow | ||
| import org.apache.spark.sql.catalyst.util.DateTimeUtils | ||
| import org.apache.spark.sql.sources.{BaseRelation, TableScan} | ||
| import org.apache.spark.sql.types.StructType | ||
| import org.apache.spark.unsafe.types.UTF8String | ||
|
|
||
|
|
||
| private[kafka010] class KafkaRelation( | ||
| override val sqlContext: SQLContext, | ||
|
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. incorrect indents |
||
| kafkaReader: KafkaTopicPartitionOffsetReader, | ||
| executorKafkaParams: ju.Map[String, Object], | ||
| sourceOptions: Map[String, String], | ||
| failOnDataLoss: Boolean, | ||
| startingOffsets: KafkaOffsets, | ||
| endingOffsets: KafkaOffsets) | ||
| extends BaseRelation with TableScan with Logging { | ||
|
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. incorrect indent |
||
| assert(startingOffsets != LatestOffsets, | ||
| "Starting offset not allowed to be set to latest offsets.") | ||
| assert(endingOffsets != EarliestOffsets, | ||
| "Ending offset not allowed to be set to earliest offsets.") | ||
|
|
||
| private val pollTimeoutMs = sourceOptions.getOrElse( | ||
| "kafkaConsumer.pollTimeoutMs", | ||
| sqlContext.sparkContext.conf.getTimeAsMs("spark.network.timeout", "120s").toString | ||
| ).toLong | ||
|
|
||
| override def schema: StructType = KafkaTopicPartitionOffsetReader.kafkaSchema | ||
|
|
||
| override def buildScan(): RDD[Row] = { | ||
| // Leverage the KafkaReader to obtain the relevant partition offsets | ||
| val fromPartitionOffsets = getPartitionOffsets(startingOffsets) | ||
| val untilPartitionOffsets = getPartitionOffsets(endingOffsets) | ||
| // Obtain topicPartitions in both from and until partition offset, ignoring | ||
| // topic partitions that were added and/or deleted between the two above calls. | ||
| if (fromPartitionOffsets.keySet != untilPartitionOffsets.keySet) { | ||
| implicit val topicOrdering: Ordering[TopicPartition] = Ordering.by(t => t.topic()) | ||
| val fromTopics = fromPartitionOffsets.keySet.toList.sorted.mkString(",") | ||
| val untilTopics = untilPartitionOffsets.keySet.toList.sorted.mkString(",") | ||
| throw new IllegalStateException("different topic partitions " + | ||
| s"for starting offsets topics[${fromTopics}] and " + | ||
| s"ending offsets topics[${untilTopics}]") | ||
| } | ||
|
|
||
| // Calculate offset ranges | ||
| val offsetRanges = untilPartitionOffsets.keySet.map { tp => | ||
| val fromOffset = fromPartitionOffsets.get(tp).getOrElse { | ||
| // This should not happen since topicPartitions contains all partitions not in | ||
| // fromPartitionOffsets | ||
| throw new IllegalStateException(s"$tp doesn't have a from offset") | ||
| } | ||
| val untilOffset = untilPartitionOffsets(tp) | ||
| KafkaSourceRDDOffsetRange(tp, fromOffset, untilOffset, None) | ||
| }.toArray | ||
|
|
||
| logInfo("GetBatch generating RDD of offset range: " + | ||
| offsetRanges.sortBy(_.topicPartition.toString).mkString(", ")) | ||
|
|
||
| // Create an RDD that reads from Kafka and get the (key, value) pair as byte arrays. | ||
| val rdd = new KafkaSourceRDD( | ||
|
Member
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 found |
||
| sqlContext.sparkContext, executorKafkaParams, offsetRanges, | ||
| pollTimeoutMs, failOnDataLoss, reuseKafkaConsumer = false).map { cr => | ||
| InternalRow( | ||
| cr.key, | ||
| cr.value, | ||
| UTF8String.fromString(cr.topic), | ||
| cr.partition, | ||
| cr.offset, | ||
| DateTimeUtils.fromJavaTimestamp(new java.sql.Timestamp(cr.timestamp)), | ||
| cr.timestampType.id) | ||
| } | ||
| sqlContext.internalCreateDataFrame(rdd, schema).rdd | ||
| } | ||
|
|
||
| private def getPartitionOffsets(kafkaOffsets: KafkaOffsets): Map[TopicPartition, Long] = { | ||
| def validateTopicPartitions(partitions: Set[TopicPartition], | ||
| partitionOffsets: Map[TopicPartition, Long]): Map[TopicPartition, Long] = { | ||
| assert(partitions == partitionOffsets.keySet, | ||
| "If startingOffsets contains specific offsets, you must specify all TopicPartitions.\n" + | ||
| "Use -1 for latest, -2 for earliest, if you don't care.\n" + | ||
| s"Specified: ${partitionOffsets.keySet} Assigned: ${partitions}") | ||
| logDebug(s"Partitions assigned to consumer: $partitions. Seeking to $partitionOffsets") | ||
| partitionOffsets | ||
| } | ||
| val partitions = kafkaReader.fetchTopicPartitions() | ||
| // Obtain TopicPartition offsets with late binding support | ||
| kafkaOffsets match { | ||
| case EarliestOffsets => partitions.map { | ||
| case tp => tp -> KafkaOffsets.EARLIEST | ||
| }.toMap | ||
| case LatestOffsets => partitions.map { | ||
| case tp => tp -> KafkaOffsets.LATEST | ||
| }.toMap | ||
| case SpecificOffsets(partitionOffsets) => | ||
| validateTopicPartitions(partitions, partitionOffsets) | ||
| } | ||
| } | ||
| } | ||
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.
nit: offset range is $range