-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-13603: Allow the empty active segment to have missing offset index during recovery #11345
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 27 commits
ecb8692
9904830
ac6e9cd
a2c51d9
82913f0
b915fe2
4df64d8
0f2f0a8
c50bff2
603627f
56d3f06
425a7a7
f345831
069ac5b
049c856
56d64cb
7bb37b8
40506eb
6f70af3
23314ea
d775f14
4d88092
099cb42
229a537
14239c0
8b7e0c9
0c8e48b
25821db
47ba1d5
885db15
5bb1a47
e00906a
bd21bb2
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 |
|---|---|---|
|
|
@@ -930,7 +930,7 @@ class UnifiedLog(@volatile var logStartOffset: Long, | |
| s"next offset: ${localLog.logEndOffset}, " + | ||
| s"and messages: $validRecords") | ||
|
|
||
| if (localLog.unflushedMessages >= config.flushInterval) flush() | ||
| if (localLog.unflushedMessages >= config.flushInterval) flush(false) | ||
| } | ||
| appendInfo | ||
| } | ||
|
|
@@ -1498,28 +1498,44 @@ class UnifiedLog(@volatile var logStartOffset: Long, | |
| producerStateManager.takeSnapshot() | ||
| updateHighWatermarkWithLogEndOffset() | ||
| // Schedule an asynchronous flush of the old segment | ||
| scheduler.schedule("flush-log", () => flush(newSegment.baseOffset)) | ||
| scheduler.schedule("flush-log", () => flushUptoOffsetExclusive(newSegment.baseOffset)) | ||
| newSegment | ||
| } | ||
|
|
||
| /** | ||
| * Flush all local log segments | ||
| * | ||
| * @param forceFlushActiveSegment should be true during a clean shutdown, and false otherwise. The reason is that | ||
| * we have to pass logEndOffset + 1 to the `localLog.flush(offset: Long): Unit` function to flush empty | ||
| * active segments, which is important to make sure we persist the active segment file during shutdown, particularly | ||
| * when it's empty. | ||
| */ | ||
| def flush(): Unit = flush(logEndOffset) | ||
| def flush(forceFlushActiveSegment: Boolean): Unit = flush(logEndOffset, forceFlushActiveSegment) | ||
|
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 addition of this flag seems to be an optimization so that we are not forced to flush newly created (empty) segments. Do you have a sense for how valuable this optimization is? The additional noise it adds to the log API is a bit unfortunate, so it would be helpful to understand what we're getting in return.
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. I will run a benchmark next week.
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. @hachikuji The PR description needs to be updated, but the main take away from this PR is that it helps fix a durability corner case during clean shutdown. Before this PR, an empty active segment doesn't get flushed to disk during clean shutdown. This PR fixes it, and after this PR, it gets flushed. Without this fix, the loss of an empty active segment file on disk (after a clean shutdown) can cause the replica to lose track of logEndOffset. Alongside the above fix, the PR also prevents some unwanted logging side effect in the recovery code path in LogLoader. I agree that the noise to the flush API signature is unfortunate. I'm not sure if we can refactor to introduce a better API that can avoid the noise. But given that this PR fixes a durability case, the PR seems useful overall.
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. @kowshik What I am suggesting is that we could do the inclusive flush in all cases in order to simplify the API if the performance cost is minimal. In addition to the simpler API, it would fix the problem of losing track of the log end offset in the general case (not just for clean shutdown).
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. IIUC that would increase the # of fsyncs that we'd typically do during segment rolls from 1 to 2. I'm not sure if we should make that change in this PR.
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. @kowshik this is exactly what I wanted to test. I will start two clusters and see the perf difference between fsync twice and once during segment rolls. Will post the results here.
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. Fsync is expensive. If we are introducing an extra fsync to disk during segment rolls, this could need extensive testing if we go down this route. cc @junrao
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. If we have 50MB/s produce input in a three-broker cluster and segment.size is 100MB, it is on average 1 extra fsync every 2 seconds. Given the extra fsync is to sync an empty file, it may not be a big deal. |
||
|
|
||
| /** | ||
| * Flush local log segments for all offsets up to offset-1 | ||
| * | ||
| * @param offset The offset to flush up to (non-inclusive); the new recovery point | ||
| */ | ||
| def flush(offset: Long): Unit = { | ||
| maybeHandleIOException(s"Error while flushing log for $topicPartition in dir ${dir.getParent} with offset $offset") { | ||
| if (offset > localLog.recoveryPoint) { | ||
| debug(s"Flushing log up to offset $offset, last flushed: $lastFlushTime, current time: ${time.milliseconds()}, " + | ||
| def flushUptoOffsetExclusive(offset: Long): Unit = flush(offset, false) | ||
|
|
||
| /** | ||
| * Flush local log segments for all offsets up to offset-1 if includingOffset=false; up to offset | ||
| * if includingOffset=true. The recovery point is set to offset-1. | ||
|
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 comment is inaccurate. The recovery point is always offset. |
||
| * | ||
| * @param offset The offset to flush up to (non-inclusive); the new recovery point | ||
|
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. Should we get rid of "(non-inclusive)"? |
||
| * @param includingOffset Whether the flush includes the provided offset. | ||
| */ | ||
| private def flush(offset: Long, includingOffset: Boolean): Unit = { | ||
|
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. Could we add a comment on how includingOffset affects the recovery point? |
||
| val flushOffset = if (includingOffset) offset + 1 else offset | ||
| val newRecoveryPoint = offset | ||
| maybeHandleIOException(s"Error while flushing log for $topicPartition in dir ${dir.getParent} with offset $flushOffset and recovery point $newRecoveryPoint") { | ||
|
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. Instead of $flushOffset, perhaps it's clearer to use "$offset(ex/inclusive)"? Ditto for the debug logging below. |
||
| if (flushOffset > localLog.recoveryPoint) { | ||
| debug(s"Flushing log up to offset $flushOffset with recovery point $newRecoveryPoint, last flushed: $lastFlushTime, current time: ${time.milliseconds()}, " + | ||
|
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. Instead of $flushOffset, could we change to "$offset(ex/inclusive)"? |
||
| s"unflushed: ${localLog.unflushedMessages}") | ||
| localLog.flush(offset) | ||
| localLog.flush(flushOffset) | ||
| lock synchronized { | ||
| localLog.markFlushed(offset) | ||
| localLog.markFlushed(newRecoveryPoint) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -211,8 +211,8 @@ final class KafkaMetadataLog private ( | |
| new LogOffsetMetadata(hwm.messageOffset, segmentPosition) | ||
| } | ||
|
|
||
| override def flush(): Unit = { | ||
| log.flush() | ||
| override def flush(inclusive: Boolean): Unit = { | ||
|
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. Should inclusive be renamed to forceFlushActiveSegment? |
||
| log.flush(inclusive) | ||
| } | ||
|
|
||
| override def lastFlushedOffset(): Long = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ package kafka.log | |
|
|
||
| import java.io.{BufferedWriter, File, FileWriter} | ||
| import java.nio.ByteBuffer | ||
| import java.nio.file.{Files, Paths} | ||
| import java.nio.file.{Files, NoSuchFileException, Paths} | ||
| import java.util.Properties | ||
| import kafka.api.{ApiVersion, KAFKA_0_11_0_IV0} | ||
| import kafka.server.epoch.{EpochEntry, LeaderEpochFileCache} | ||
|
|
@@ -760,7 +760,7 @@ class LogLoaderTest { | |
| val lastOffset = log.logEndOffset | ||
| // After segment is closed, the last entry in the time index should be (largest timestamp -> last offset). | ||
| val lastTimeIndexOffset = log.logEndOffset - 1 | ||
| val lastTimeIndexTimestamp = log.activeSegment.largestTimestamp | ||
| val lastTimeIndexTimestamp = log.activeSegment.largestTimestamp | ||
| // Depending on when the last time index entry is inserted, an entry may or may not be inserted into the time index. | ||
| val numTimeIndexEntries = log.activeSegment.timeIndex.entries + { | ||
| if (log.activeSegment.timeIndex.lastEntry.offset == log.logEndOffset - 1) 0 else 1 | ||
|
|
@@ -786,7 +786,7 @@ class LogLoaderTest { | |
| log = createLog(logDir, logConfig, recoveryPoint = recoveryPoint, lastShutdownClean = false) | ||
| // the recovery point should not be updated after unclean shutdown until the log is flushed | ||
| verifyRecoveredLog(log, recoveryPoint) | ||
| log.flush() | ||
| log.flush(false) | ||
| verifyRecoveredLog(log, lastOffset) | ||
| log.close() | ||
| } | ||
|
|
@@ -1677,4 +1677,46 @@ class LogLoaderTest { | |
| s"Found offsets with missing producer state snapshot files: $offsetsWithMissingSnapshotFiles") | ||
| assertFalse(logDir.list().exists(_.endsWith(UnifiedLog.DeletedFileSuffix)), "Expected no files to be present with the deleted file suffix") | ||
| } | ||
|
|
||
| @Test | ||
| def testRecoverWithEmptyActiveSegment(): Unit = { | ||
| val numMessages = 100 | ||
| val messageSize = 100 | ||
| val segmentSize = 7 * messageSize | ||
| val indexInterval = 3 * messageSize | ||
| val logConfig = LogTestUtils.createLogConfig(segmentBytes = segmentSize, indexIntervalBytes = indexInterval, segmentIndexBytes = 4096) | ||
| var log = createLog(logDir, logConfig) | ||
| for(i <- 0 until numMessages) | ||
| log.appendAsLeader(TestUtils.singletonRecords(value = TestUtils.randomBytes(messageSize), | ||
| timestamp = mockTime.milliseconds + i * 10), leaderEpoch = 0) | ||
| assertEquals(numMessages, log.logEndOffset, | ||
| "After appending %d messages to an empty log, the log end offset should be %d".format(numMessages, numMessages)) | ||
| log.roll() | ||
| log.flush(false) | ||
| assertThrows(classOf[NoSuchFileException], () => log.activeSegment.sanityCheck(true)) | ||
| var lastOffset = log.logEndOffset | ||
|
|
||
| log = createLog(logDir, logConfig, recoveryPoint = lastOffset, lastShutdownClean = false) | ||
|
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. Should we call log.closeHandlers() before assigning a new value to log? Otherwise, it seems that we are leaking file handles. |
||
| assertEquals(lastOffset, log.recoveryPoint, s"Unexpected recovery point") | ||
| assertEquals(numMessages, log.logEndOffset, s"Should have $numMessages messages when log is reopened w/o recovery") | ||
| assertEquals(0, log.activeSegment.timeIndex.entries, "Should have same number of time index entries as before.") | ||
| log.activeSegment.sanityCheck(true) // this should not throw | ||
|
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. Could we add a comment why this check won't throw after re-instantiating the log? |
||
|
|
||
| for(i <- 0 until numMessages) | ||
| log.appendAsLeader(TestUtils.singletonRecords(value = TestUtils.randomBytes(messageSize), | ||
| timestamp = mockTime.milliseconds + i * 10), leaderEpoch = 0) | ||
| log.roll() | ||
| assertThrows(classOf[NoSuchFileException], () => log.activeSegment.sanityCheck(true)) | ||
| log.flush(true) | ||
| log.activeSegment.sanityCheck(true) // this should not throw | ||
| lastOffset = log.logEndOffset | ||
|
|
||
| log = createLog(logDir, logConfig, recoveryPoint = lastOffset, lastShutdownClean = false) | ||
| assertEquals(lastOffset, log.recoveryPoint, s"Unexpected recovery point") | ||
| assertEquals(2 * numMessages, log.logEndOffset, s"Should have $numMessages messages when log is reopened w/o recovery") | ||
| assertEquals(0, log.activeSegment.timeIndex.entries, "Should have same number of time index entries as before.") | ||
| log.activeSegment.sanityCheck(true) // this should not throw | ||
|
|
||
| log.close() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1099,7 +1099,7 @@ class UnifiedLogTest { | |
|
|
||
| // even if we flush within the active segment, the snapshot should remain | ||
| log.appendAsLeader(TestUtils.singletonRecords("baz".getBytes), leaderEpoch = 0) | ||
| log.flush(4L) | ||
| log.flushUptoOffsetExclusive(4L) | ||
| assertEquals(Some(3L), log.latestProducerSnapshotOffset) | ||
| } | ||
|
|
||
|
|
@@ -1293,7 +1293,7 @@ class UnifiedLogTest { | |
| val memoryRecords = MemoryRecords.readableRecords(buffer) | ||
|
|
||
| log.appendAsFollower(memoryRecords) | ||
| log.flush() | ||
| log.flush(false) | ||
|
|
||
| val fetchedData = LogTestUtils.readLog(log, 0, Int.MaxValue) | ||
|
|
||
|
|
@@ -1630,6 +1630,21 @@ class UnifiedLogTest { | |
| assertThrows(classOf[OffsetOutOfRangeException], () => LogTestUtils.readLog(log, 1026, 1000)) | ||
| } | ||
|
|
||
| @Test | ||
| def testFlushingEmptyActiveSegments(): Unit = { | ||
|
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. Is it possible to add another test in
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. Done with other comments. Will work on this one next week.
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. Sounds good. |
||
| val logConfig = LogTestUtils.createLogConfig() | ||
| val log = createLog(logDir, logConfig) | ||
| val message = TestUtils.singletonRecords(value = "Test".getBytes, timestamp = mockTime.milliseconds) | ||
| log.appendAsLeader(message, leaderEpoch = 0) | ||
| log.roll() | ||
| assertEquals(2, logDir.listFiles(_.getName.endsWith(".log")).length) | ||
| assertEquals(1, logDir.listFiles(_.getName.endsWith(".index")).length) | ||
| assertEquals(0, log.activeSegment.size) | ||
| log.flush(true) | ||
|
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. Just before this line, can we assert that the active segment is empty? this sets up the reason why we are force flushing it here. |
||
| assertEquals(2, logDir.listFiles(_.getName.endsWith(".log")).length) | ||
| assertEquals(2, logDir.listFiles(_.getName.endsWith(".index")).length) | ||
| } | ||
|
|
||
| /** | ||
| * Test that covers reads and writes on a multisegment log. This test appends a bunch of messages | ||
| * and then reads them all back and checks that the message read and offset matches what was appended. | ||
|
|
@@ -1643,7 +1658,7 @@ class UnifiedLogTest { | |
| val messageSets = (0 until numMessages).map(i => TestUtils.singletonRecords(value = i.toString.getBytes, | ||
| timestamp = mockTime.milliseconds)) | ||
| messageSets.foreach(log.appendAsLeader(_, leaderEpoch = 0)) | ||
| log.flush() | ||
| log.flush(false) | ||
|
|
||
| /* do successive reads to ensure all our messages are there */ | ||
| var offset = 0L | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -443,7 +443,7 @@ private void onBecomeLeader(long currentTimeMs) { | |
| private void flushLeaderLog(LeaderState<T> state, long currentTimeMs) { | ||
| // We update the end offset before flushing so that parked fetches can return sooner. | ||
| updateLeaderEndOffsetAndTimestamp(state, currentTimeMs); | ||
| log.flush(); | ||
| log.flush(false); | ||
|
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. This is probably ok. The way that kraft manages log retention is a bit different from normal partitions. In general, segments are only deleted once we have a snapshot which covers all of their data. That should mean that there is no risk today of losing the log end offset even if an unflushed empty segment is lost. In other words, even if all of the segments are lost, we should still have a snapshot to derive the log end offset from. cc @jsancio
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. Yes. Looking at the code, we don't need to have segments to determine the log end offset. When loading the |
||
| } | ||
|
|
||
| private boolean maybeTransitionToLeader(CandidateState state, long currentTimeMs) { | ||
|
|
@@ -1136,7 +1136,7 @@ private void appendAsFollower( | |
| Records records | ||
| ) { | ||
| LogAppendInfo info = log.appendAsFollower(records); | ||
| log.flush(); | ||
| log.flush(false); | ||
|
|
||
| OffsetAndEpoch endOffset = endOffset(); | ||
| kafkaRaftMetrics.updateFetchedRecords(info.lastOffset - info.firstOffset + 1); | ||
|
|
@@ -2362,6 +2362,7 @@ public Optional<SnapshotWriter<T>> createSnapshot( | |
|
|
||
| @Override | ||
| public void close() { | ||
| log.flush(true); | ||
| if (kafkaRaftMetrics != null) { | ||
| kafkaRaftMetrics.close(); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -184,8 +184,10 @@ default ValidOffsetAndEpoch validateOffsetAndEpoch(long offset, int epoch) { | |
|
|
||
| /** | ||
| * Flush the current log to disk. | ||
| * | ||
| * @param inclusive Whether the flush includes the log end offset. Should be `true` during close; otherwise false. | ||
| */ | ||
| void flush(); | ||
| void flush(boolean inclusive); | ||
|
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. We use forceFlushActiveSegment in UnifiedLog.flush(). Should we be consistent with the name? |
||
|
|
||
| /** | ||
| * Possibly perform cleaning of snapshots and logs | ||
|
|
||
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.
This condition is correct if hadCleanShutdown is false.
If hadCleanShutdown is true, it seems the condition should be
segment.baseOffset <=params.recoveryPointCheckpoint. Or maybe we should just always log the error if hadCleanShutdown is true.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.
I think if hadCleanShutdown is true, it should never throw NoSuchFileException unless there is a bug in the code.
Added the hadCleanShutdown check anyways to catch potential issues:
if (params.hadCleanShutdown || segment.baseOffset < params.recoveryPointCheckpoint)