Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.protocol.types.Struct;
import org.apache.kafka.common.utils.ByteBufferOutputStream;
import org.apache.kafka.common.utils.Utils;

import java.io.DataOutputStream;
import java.io.IOException;
Expand Down Expand Up @@ -587,6 +588,35 @@ public void appendUncheckedWithOffset(long offset, LegacyRecord record) {
}
}

/**
* Append a record without doing offset/magic validation (this should only be used in testing).
*
* @param offset The offset of the record
* @param record The record to add
*/
public void appendUncheckedWithOffset(long offset, SimpleRecord record) throws IOException {

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'm a bit nervous about this method being in the public interface. Could we make it package private and then add a test utility method in the same package that calls it, exposing it only for tests?

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.

Oh, we already have a public appendUncheckedWithOffset for LegacyRecord.

@hachikuji hachikuji May 12, 2020

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.

We could move these methods to a TestMemoryRecordsBuilder or something. I think risk of misuse is probably not high though.

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.

Makes sense. Since we already had one such method, I'm ok with keeping as is.

if (magic >= RecordBatch.MAGIC_VALUE_V2) {
int offsetDelta = (int) (offset - baseOffset);
long timestamp = record.timestamp();
if (firstTimestamp == null)
firstTimestamp = timestamp;

int sizeInBytes = DefaultRecord.writeTo(appendStream,
offsetDelta,
timestamp - firstTimestamp,
record.key(),
record.value(),
record.headers());
recordWritten(offset, timestamp, sizeInBytes);
} else {
LegacyRecord legacyRecord = LegacyRecord.create(magic,
record.timestamp(),
Utils.toNullableArray(record.key()),
Utils.toNullableArray(record.value()));
appendUncheckedWithOffset(offset, legacyRecord);
}
}

/**
* Append a record at the next sequential offset.
* @param record the record to add
Expand Down
21 changes: 9 additions & 12 deletions core/src/main/scala/kafka/log/LogValidator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -366,16 +366,6 @@ private[log] object LogValidator extends Logging {
else None
}

def validateOffset(batchIndex: Int, record: Record, expectedOffset: Long): Option[ApiRecordError] = {
// inner records offset should always be continuous
if (record.offset != expectedOffset) {
brokerTopicStats.allTopicsStats.invalidOffsetOrSequenceRecordsPerSec.mark()
Some(ApiRecordError(Errors.INVALID_RECORD, new RecordError(batchIndex,
s"Inner record $record inside the compressed record batch does not have " +
s"incremental offsets, expected offset is $expectedOffset in topic partition $topicPartition.")))
} else None
}

// No in place assignment situation 1
var inPlaceAssignment = sourceCodec == targetCodec

Expand Down Expand Up @@ -423,8 +413,15 @@ private[log] object LogValidator extends Logging {
if (batch.magic > RecordBatch.MAGIC_VALUE_V0 && toMagic > RecordBatch.MAGIC_VALUE_V0) {

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.

First, I don't fully understand the different between version 0, 1, and 2. I am concern if this check (if statement) it still accurate.

  1. Why do we only update the maxTimestamp if the version is not v0?
  2. Why do we disallow in place assignment of records with invalid offsets if the version is not v0?

For 1, is it because we are only allow to update it in v1 and v2? E.g.

if (toMagic >= RecordBatch.MAGIC_VALUE_V1)
        batch.setMaxTimestamp(timestampType, maxTimestamp)

For 2. is it because in place assignment is always false for v0? E.g.:

    if (firstBatch.magic != toMagic || toMagic == RecordBatch.MAGIC_VALUE_V0)
      inPlaceAssignment = false

@hachikuji hachikuji May 12, 2020

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.

For 1), it is because the v0 format has no field for the timestamp. For 2), you are right: we never do in place assignment for v0 because it does not support relative internal offsets.

if (record.timestamp > maxTimestamp)
maxTimestamp = record.timestamp
validateOffset(batchIndex, record, expectedOffset)
} else None

// Some older clients do not implement the V1 internal offsets correctly.
// Historically the broker handled this by rewriting the batches rather
// than rejecting the request. We must continue this handling here to avoid
// breaking these clients.
if (record.offset != expectedOffset)

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.

Can we add a comment here?

inPlaceAssignment = false
}
None
}
}

Expand Down
49 changes: 46 additions & 3 deletions core/src/test/scala/unit/kafka/log/LogValidatorTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ import java.util.concurrent.TimeUnit

import kafka.api.{ApiVersion, KAFKA_2_0_IV1, KAFKA_2_3_IV1}
import kafka.common.{LongRef, RecordValidationException}
import kafka.log.LogValidator.ValidationAndOffsetAssignResult
import kafka.message._
import kafka.metrics.KafkaYammerMetrics
import kafka.server.BrokerTopicStats
import kafka.utils.TestUtils.meterCount
import org.apache.kafka.common.InvalidRecordException
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.errors.{InvalidTimestampException, UnsupportedCompressionTypeException, UnsupportedForMessageFormatException}
import org.apache.kafka.common.record._
import org.apache.kafka.common.utils.Time
import org.apache.kafka.common.{InvalidRecordException, TopicPartition}
import org.apache.kafka.test.TestUtils
import org.junit.Assert._
import org.junit.Test
Expand Down Expand Up @@ -64,6 +64,29 @@ class LogValidatorTest {
checkAllowMultiBatch(RecordBatch.MAGIC_VALUE_V1, CompressionType.NONE, CompressionType.GZIP)
}

@Test
def testValidationOfBatchesWithNonSequentialInnerOffsets(): Unit = {
def testMessageValidation(magicValue: Byte): Unit = {
val numRecords = 20
val invalidRecords = recordsWithNonSequentialInnerOffsets(magicValue, CompressionType.GZIP, numRecords)

// Validation for v2 and above is strict for this case. For older formats, we fix invalid
// internal offsets by rewriting the batch.
if (magicValue >= RecordBatch.MAGIC_VALUE_V2) {
assertThrows[InvalidRecordException] {
validateMessages(invalidRecords, magicValue, CompressionType.GZIP, CompressionType.GZIP)
}
} else {
val result = validateMessages(invalidRecords, magicValue, CompressionType.GZIP, CompressionType.GZIP)
assertEquals(0 until numRecords, result.validatedRecords.records.asScala.map(_.offset))
}
}

for (version <- RecordVersion.values) {
testMessageValidation(version.value)
}
}

@Test
def testMisMatchMagic(): Unit = {
checkMismatchMagic(RecordBatch.MAGIC_VALUE_V0, RecordBatch.MAGIC_VALUE_V1, CompressionType.GZIP)
Expand All @@ -88,7 +111,10 @@ class LogValidatorTest {
assertTrue(meterCount(s"${BrokerTopicStats.InvalidMagicNumberRecordsPerSec}") > 0)
}

private def validateMessages(records: MemoryRecords, magic: Byte, sourceCompressionType: CompressionType, targetCompressionType: CompressionType): Unit = {
private def validateMessages(records: MemoryRecords,
magic: Byte,
sourceCompressionType: CompressionType,
targetCompressionType: CompressionType): ValidationAndOffsetAssignResult = {
LogValidator.validateMessagesAndAssignOffsets(records,
topicPartition,
new LongRef(0L),
Expand Down Expand Up @@ -1404,6 +1430,23 @@ class LogValidatorTest {
}
}

private def recordsWithNonSequentialInnerOffsets(magicValue: Byte,
codec: CompressionType,
numRecords: Int): MemoryRecords = {
val records = (0 until numRecords).map { id =>
new SimpleRecord(id.toString.getBytes)
}

val buffer = ByteBuffer.allocate(1024)
val builder = MemoryRecords.builder(buffer, magicValue, codec, TimestampType.CREATE_TIME, 0L)

records.foreach { record =>
builder.appendUncheckedWithOffset(0, record)
}

builder.build()
}

private def recordsWithInvalidInnerMagic(batchMagicValue: Byte,
recordMagicValue: Byte,
codec: CompressionType): MemoryRecords = {
Expand Down