-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-7210: Add a system test to verify the log compaction #5226
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 8 commits
159593a
29c402b
3374c2f
5464089
84c0c95
fdd7281
c2545b5
02e8edf
6f7dff7
80332b9
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,348 @@ | ||
| /** | ||
| * 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 kafka.tools | ||
|
|
||
| import java.io._ | ||
| import java.nio.ByteBuffer | ||
| import java.nio.charset.StandardCharsets.UTF_8 | ||
| import java.nio.file.{Files, Path} | ||
| import java.time.Duration | ||
| import java.util.{Properties, Random} | ||
|
|
||
| import joptsimple.OptionParser | ||
| import kafka.utils._ | ||
| import org.apache.kafka.clients.admin.NewTopic | ||
| import org.apache.kafka.clients.{CommonClientConfigs, admin} | ||
| import org.apache.kafka.clients.consumer.{ConsumerConfig, KafkaConsumer} | ||
| import org.apache.kafka.clients.producer.{KafkaProducer, ProducerConfig, ProducerRecord} | ||
| import org.apache.kafka.common.config.TopicConfig | ||
| import org.apache.kafka.common.serialization.{ByteArraySerializer, StringDeserializer} | ||
| import org.apache.kafka.common.utils.Utils | ||
|
|
||
| import scala.collection.JavaConverters._ | ||
|
|
||
| /** | ||
| * This is a torture test that runs against an existing broker | ||
| * | ||
| * Here is how it works: | ||
| * | ||
| * It produces a series of specially formatted messages to one or more partitions. Each message it produces | ||
| * it logs out to a text file. The messages have a limited set of keys, so there is duplication in the key space. | ||
| * | ||
| * The broker will clean its log as the test runs. | ||
| * | ||
| * When the specified number of messages have been produced we create a consumer and consume all the messages in the topic | ||
| * and write that out to another text file. | ||
| * | ||
| * Using a stable unix sort we sort both the producer log of what was sent and the consumer log of what was retrieved by the message key. | ||
| * Then we compare the final message in both logs for each key. If this final message is not the same for all keys we | ||
| * print an error and exit with exit code 1, otherwise we print the size reduction and exit with exit code 0. | ||
| */ | ||
| object LogCompactionTester { | ||
|
|
||
| //maximum line size while reading produced/consumed record text file | ||
| private val ReadAheadLimit = 4906 | ||
|
|
||
| def main(args: Array[String]) { | ||
| val parser = new OptionParser(false) | ||
| val numMessagesOpt = parser.accepts("messages", "The number of messages to send or consume.") | ||
| .withRequiredArg | ||
| .describedAs("count") | ||
| .ofType(classOf[java.lang.Long]) | ||
| .defaultsTo(Long.MaxValue) | ||
| val messageCompressionOpt = parser.accepts("compression-type", "message compression type") | ||
| .withOptionalArg | ||
| .describedAs("compressionType") | ||
| .ofType(classOf[java.lang.String]) | ||
| .defaultsTo("none") | ||
| val numDupsOpt = parser.accepts("duplicates", "The number of duplicates for each key.") | ||
| .withRequiredArg | ||
| .describedAs("count") | ||
| .ofType(classOf[java.lang.Integer]) | ||
| .defaultsTo(5) | ||
| val brokerOpt = parser.accepts("bootstrap-server", "The server(s) to connect to.") | ||
| .withRequiredArg | ||
| .describedAs("url") | ||
| .ofType(classOf[String]) | ||
| val topicsOpt = parser.accepts("topics", "The number of topics to test.") | ||
| .withRequiredArg | ||
| .describedAs("count") | ||
| .ofType(classOf[java.lang.Integer]) | ||
| .defaultsTo(1) | ||
| val percentDeletesOpt = parser.accepts("percent-deletes", "The percentage of updates that are deletes.") | ||
| .withRequiredArg | ||
| .describedAs("percent") | ||
| .ofType(classOf[java.lang.Integer]) | ||
| .defaultsTo(0) | ||
| val sleepSecsOpt = parser.accepts("sleep", "Time in milliseconds to sleep between production and consumption.") | ||
| .withRequiredArg | ||
| .describedAs("ms") | ||
| .ofType(classOf[java.lang.Integer]) | ||
| .defaultsTo(0) | ||
|
|
||
| val options = parser.parse(args: _*) | ||
|
|
||
| if (args.length == 0) | ||
| CommandLineUtils.printUsageAndDie(parser, "A tool to test log compaction. Valid options are: ") | ||
|
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 think I'd say something about the fact that no arguments were passed, but some are expected.
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. This line used for printing usage. next line checks for required arguments. Same pattern we are using in other tools. |
||
|
|
||
| CommandLineUtils.checkRequiredArgs(parser, options, brokerOpt, numMessagesOpt) | ||
|
|
||
| // parse options | ||
| val messages = options.valueOf(numMessagesOpt).longValue | ||
| val compressionType = options.valueOf(messageCompressionOpt) | ||
| val percentDeletes = options.valueOf(percentDeletesOpt).intValue | ||
| val dups = options.valueOf(numDupsOpt).intValue | ||
| val brokerUrl = options.valueOf(brokerOpt) | ||
| val topicCount = options.valueOf(topicsOpt).intValue | ||
| val sleepSecs = options.valueOf(sleepSecsOpt).intValue | ||
|
|
||
| val testId = new Random().nextLong | ||
| val topics = (0 until topicCount).map("log-cleaner-test-" + testId + "-" + _).toArray | ||
| createTopics(brokerUrl, topics.toSeq) | ||
|
|
||
| println(s"Producing $messages messages..to topics ${topics.mkString(",")}") | ||
| val producedDataFilePath = produceMessages(brokerUrl, topics, messages, compressionType, dups, percentDeletes) | ||
| println(s"Sleeping for $sleepSecs seconds...") | ||
| Thread.sleep(sleepSecs * 1000) | ||
| println("Consuming messages...") | ||
| val consumedDataFilePath = consumeMessages(brokerUrl, topics) | ||
|
|
||
| val producedLines = lineCount(producedDataFilePath) | ||
| val consumedLines = lineCount(consumedDataFilePath) | ||
| val reduction = 100 * (1.0 - consumedLines.toDouble / producedLines.toDouble) | ||
| println(f"$producedLines%d rows of data produced, $consumedLines%d rows of data consumed ($reduction%.1f%% reduction).") | ||
|
|
||
| println("De-duplicating and validating output files...") | ||
| validateOutput(producedDataFilePath.toFile, consumedDataFilePath.toFile) | ||
| Utils.delete(producedDataFilePath.toFile) | ||
| Utils.delete(consumedDataFilePath.toFile) | ||
| //if you change this line, we need to update test_log_compaction_tool.py system test | ||
| println("Data verification is completed") | ||
| } | ||
|
|
||
| def createTopics(brokerUrl: String, topics: Seq[String]): Unit = { | ||
| val adminConfig = new Properties | ||
| adminConfig.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, brokerUrl) | ||
| val adminClient = admin.AdminClient.create(adminConfig) | ||
|
|
||
| try { | ||
| val topicConfigs = Map(TopicConfig.CLEANUP_POLICY_CONFIG -> TopicConfig.CLEANUP_POLICY_COMPACT) | ||
| val newTopics = topics.map(name => new NewTopic(name, 1, 1).configs(topicConfigs.asJava)).asJava | ||
| adminClient.createTopics(newTopics).all.get | ||
|
|
||
| var pendingTopics: Seq[String] = Seq() | ||
| TestUtils.waitUntilTrue(() => { | ||
| val allTopics = adminClient.listTopics.names.get.asScala.toSeq | ||
| pendingTopics = topics.filter(topicName => !allTopics.contains(topicName)) | ||
| pendingTopics.isEmpty | ||
| }, s"timed out waiting for topics : $pendingTopics") | ||
|
|
||
| } finally adminClient.close() | ||
| } | ||
|
|
||
| def lineCount(filPath: Path): Int = Files.readAllLines(filPath).size | ||
|
|
||
| def validateOutput(producedDataFile: File, consumedDataFile: File) { | ||
| val producedReader = externalSort(producedDataFile) | ||
| val consumedReader = externalSort(consumedDataFile) | ||
| val produced = valuesIterator(producedReader) | ||
| val consumed = valuesIterator(consumedReader) | ||
|
|
||
| val producedDedupedFile = new File(producedDataFile.getAbsolutePath + ".deduped") | ||
| val producedDeduped : BufferedWriter = Files.newBufferedWriter(producedDedupedFile.toPath, UTF_8) | ||
|
|
||
| val consumedDedupedFile = new File(consumedDataFile.getAbsolutePath + ".deduped") | ||
| val consumedDeduped : BufferedWriter = Files.newBufferedWriter(consumedDedupedFile.toPath, UTF_8) | ||
| var total = 0 | ||
| var mismatched = 0 | ||
| while (produced.hasNext && consumed.hasNext) { | ||
| val p = produced.next() | ||
| producedDeduped.write(p.toString) | ||
| producedDeduped.newLine() | ||
| val c = consumed.next() | ||
| consumedDeduped.write(c.toString) | ||
| consumedDeduped.newLine() | ||
| if (p != c) | ||
| mismatched += 1 | ||
| total += 1 | ||
| } | ||
| producedDeduped.close() | ||
| consumedDeduped.close() | ||
| println(s"Validated $total values, $mismatched mismatches.") | ||
| require(!produced.hasNext, "Additional values produced not found in consumer log.") | ||
| require(!consumed.hasNext, "Additional values consumed not found in producer log.") | ||
| require(mismatched == 0, "Non-zero number of row mismatches.") | ||
| // if all the checks worked out we can delete the deduped files | ||
| Utils.delete(producedDedupedFile) | ||
| Utils.delete(consumedDedupedFile) | ||
| } | ||
|
|
||
| def require(requirement: Boolean, message: => Any) { | ||
| if (!requirement) { | ||
| System.err.println(s"Data validation failed : $message") | ||
| Exit.exit(1) | ||
| } | ||
| } | ||
|
|
||
| def valuesIterator(reader: BufferedReader) = { | ||
| new IteratorTemplate[TestRecord] { | ||
| def makeNext(): TestRecord = { | ||
| var next = readNext(reader) | ||
| while (next != null && next.delete) | ||
| next = readNext(reader) | ||
| if (next == null) | ||
| allDone() | ||
| else | ||
| next | ||
| } | ||
| } | ||
| } | ||
|
|
||
| def readNext(reader: BufferedReader): TestRecord = { | ||
| var line = reader.readLine() | ||
| if (line == null) | ||
| return null | ||
| var curr = TestRecord.parse(line) | ||
| while (true) { | ||
| line = peekLine(reader) | ||
| if (line == null) | ||
| return curr | ||
| val next = TestRecord.parse(line) | ||
| if (next == null || next.topicAndKey != curr.topicAndKey) | ||
| return curr | ||
| curr = next | ||
| reader.readLine() | ||
| } | ||
| null | ||
| } | ||
|
|
||
| def peekLine(reader: BufferedReader) = { | ||
| reader.mark(ReadAheadLimit) | ||
| val line = reader.readLine | ||
| reader.reset() | ||
| line | ||
| } | ||
|
|
||
| def externalSort(file: File): BufferedReader = { | ||
| val builder = new ProcessBuilder("sort", "--key=1,2", "--stable", "--buffer-size=20%", "--temporary-directory=" + Files.createTempDirectory("log_compaction_test"), file.getAbsolutePath) | ||
| val process = builder.start | ||
| new Thread() { | ||
| override def run() { | ||
| val exitCode = process.waitFor() | ||
| if (exitCode != 0) { | ||
| System.err.println("Process exited abnormally.") | ||
| while (process.getErrorStream.available > 0) { | ||
| System.err.write(process.getErrorStream().read()) | ||
| } | ||
| } | ||
| } | ||
| }.start() | ||
| new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8), 10 * 1024 * 1024) | ||
| } | ||
|
|
||
| def produceMessages(brokerUrl: String, | ||
| topics: Array[String], | ||
| messages: Long, | ||
| compressionType: String, | ||
| dups: Int, | ||
| percentDeletes: Int): Path = { | ||
| val producerProps = new Properties | ||
| producerProps.setProperty(ProducerConfig.MAX_BLOCK_MS_CONFIG, Long.MaxValue.toString) | ||
| producerProps.setProperty(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerUrl) | ||
| producerProps.setProperty(ProducerConfig.COMPRESSION_TYPE_CONFIG, compressionType) | ||
| val producer = new KafkaProducer(producerProps, new ByteArraySerializer, new ByteArraySerializer) | ||
| try { | ||
| val rand = new Random(1) | ||
| val keyCount = (messages / dups).toInt | ||
| val producedFilePath = Files.createTempFile("kafka-log-cleaner-produced-", ".txt") | ||
| println(s"Logging produce requests to $producedFilePath") | ||
| val producedWriter: BufferedWriter = Files.newBufferedWriter(producedFilePath, UTF_8) | ||
| for (i <- 0L until (messages * topics.length)) { | ||
| val topic = topics((i % topics.length).toInt) | ||
| val key = rand.nextInt(keyCount) | ||
| val delete = (i % 100) < percentDeletes | ||
| val msg = | ||
| if (delete) | ||
| new ProducerRecord[Array[Byte], Array[Byte]](topic, key.toString.getBytes(UTF_8), null) | ||
| else | ||
| new ProducerRecord(topic, key.toString.getBytes(UTF_8), i.toString.getBytes(UTF_8)) | ||
| producer.send(msg) | ||
| producedWriter.write(TestRecord(topic, key, i, delete).toString) | ||
| producedWriter.newLine() | ||
| } | ||
| producedWriter.close() | ||
| producedFilePath | ||
| } finally { | ||
| producer.close() | ||
| } | ||
| } | ||
|
|
||
| def createConsumer(brokerUrl: String): KafkaConsumer[String, String] = { | ||
| val consumerProps = new Properties | ||
| consumerProps.setProperty(ConsumerConfig.GROUP_ID_CONFIG, "log-cleaner-test-" + new Random().nextInt(Int.MaxValue)) | ||
| consumerProps.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerUrl) | ||
| consumerProps.setProperty(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") | ||
| new KafkaConsumer(consumerProps, new StringDeserializer, new StringDeserializer) | ||
| } | ||
|
|
||
| def consumeMessages(brokerUrl: String, topics: Array[String]): Path = { | ||
| val consumer = createConsumer(brokerUrl) | ||
| consumer.subscribe(topics.seq.asJava) | ||
| val consumedFilePath = Files.createTempFile("kafka-log-cleaner-consumed-", ".txt") | ||
| println(s"Logging consumed messages to $consumedFilePath") | ||
| val consumedWriter: BufferedWriter = Files.newBufferedWriter(consumedFilePath, UTF_8) | ||
|
|
||
| try { | ||
| var done = false | ||
| while (!done) { | ||
| val consumerRecords = consumer.poll(Duration.ofSeconds(20)) | ||
| if (!consumerRecords.isEmpty) { | ||
| for (record <- consumerRecords.asScala) { | ||
| val delete = record.value == null | ||
| val value = if (delete) -1L else record.value.toLong | ||
| consumedWriter.write(TestRecord(record.topic, record.key.toInt, value, delete).toString) | ||
| consumedWriter.newLine | ||
| } | ||
| } else { | ||
| done = true | ||
| } | ||
| } | ||
| consumedFilePath | ||
| } finally { | ||
| consumedWriter.close() | ||
| consumer.close() | ||
| } | ||
| } | ||
|
|
||
| def readString(buffer: ByteBuffer): String = { | ||
| Utils.utf8(buffer) | ||
| } | ||
|
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. Can we just use
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. There's a version that takes a |
||
|
|
||
| } | ||
|
|
||
| case class TestRecord(topic: String, key: Int, value: Long, delete: Boolean) { | ||
| override def toString = topic + "\t" + key + "\t" + value + "\t" + (if (delete) "d" else "u") | ||
| def topicAndKey = topic + key | ||
| } | ||
|
|
||
| object TestRecord { | ||
| def parse(line: String): TestRecord = { | ||
| val components = line.split("\t") | ||
| new TestRecord(components(0), components(1).toInt, components(2).toLong, components(3) == "d") | ||
| } | ||
| } | ||
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.
Interesting. Does this create a new artifact? I think maybe we should go back to your previous solution that is limited to
copyDependantTestLibswith a comment explaining why it's needed. We can consider separately whether we should move away fromtestCompile project(':clients').sourceSets.test.outputin favour of the more complete solution across the board (we use test dependencies in multiple modules).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.
There's also the option of just using
https://plugins.gradle.org/plugin/com.github.hauner.jarTest/1.0, but it's best to discuss that in a separate PR.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.
makes sense. reverted the last commit. Will handle this in separate PR.
For now, added a comment to explain the need for previous solution.