Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ecb8692
Allow empty last segment to have missing offset index during recovery
ccding Sep 20, 2021
9904830
Revert "Allow empty last segment to have missing offset index during …
ccding Oct 1, 2021
ac6e9cd
Merge branch 'trunk' into last
ccding Oct 1, 2021
a2c51d9
flush empty active segments
ccding Oct 1, 2021
82913f0
add comment
ccding Oct 26, 2021
b915fe2
unit test
ccding Oct 27, 2021
4df64d8
address comments
ccding Nov 5, 2021
0f2f0a8
Merge branch 'trunk' into last
ccding Nov 8, 2021
c50bff2
flush at the right place
ccding Nov 17, 2021
603627f
Merge branch 'trunk' into last
ccding Nov 17, 2021
56d3f06
trigger test
ccding Nov 17, 2021
425a7a7
address comments
ccding Nov 19, 2021
f345831
Merge branch 'trunk' into last
ccding Nov 22, 2021
069ac5b
only do the inclusive flush in KRaft.close()
ccding Nov 22, 2021
049c856
Merge branch 'trunk' into last
ccding Nov 30, 2021
56d64cb
rename a bunch of variable/function names to address comments
ccding Nov 30, 2021
7bb37b8
fix typo and purpose
ccding Dec 1, 2021
40506eb
fix
ccding Dec 1, 2021
6f70af3
do not print error if the missing index file is after the recovery point
ccding Dec 7, 2021
23314ea
add log loader test
ccding Dec 20, 2021
d775f14
fix time stamp check
ccding Dec 20, 2021
4d88092
fix comment
ccding Dec 20, 2021
099cb42
fix log loader test
ccding Dec 21, 2021
229a537
Merge branch 'trunk' into last
ccding Dec 22, 2021
14239c0
trigger test
ccding Dec 22, 2021
8b7e0c9
Merge branch 'trunk' into last
ccding Dec 28, 2021
0c8e48b
Merge branch 'trunk' into last
ccding Jan 5, 2022
25821db
address comments
ccding Jan 10, 2022
47ba1d5
update comments and polish log messages
ccding Jan 11, 2022
885db15
improve log message
ccding Jan 24, 2022
5bb1a47
Merge branch 'trunk' into last
ccding Jan 27, 2022
e00906a
fix compile and improve log message
ccding Jan 27, 2022
bd21bb2
fix debug output
ccding Jan 27, 2022
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
5 changes: 3 additions & 2 deletions core/src/main/scala/kafka/log/LogLoader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,9 @@ object LogLoader extends Logging {
try segment.sanityCheck(timeIndexFileNewlyCreated)
catch {
case _: NoSuchFileException =>
error(s"${params.logIdentifier}Could not find offset index file corresponding to log file" +
s" ${segment.log.file.getAbsolutePath}, recovering segment and rebuilding index files...")
if (segment.baseOffset < params.recoveryPointCheckpoint)

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

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

error(s"${params.logIdentifier}Could not find offset index file corresponding to log file" +
s" ${segment.log.file.getAbsolutePath}, recovering segment and rebuilding index files...")
recoverSegment(segment, params)
case e: CorruptIndexException =>
warn(s"${params.logIdentifier}Found a corrupted index file corresponding to log file" +
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/kafka/log/LogManager.scala
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ class LogManager(logDirs: Seq[File],
val jobsForDir = logs.map { log =>
val runnable: Runnable = () => {
// flush the log to ensure latest possible recovery point
log.flush()
log.flush(true)
log.close()
}
runnable
Expand Down Expand Up @@ -1242,7 +1242,7 @@ class LogManager(logDirs: Seq[File],
debug(s"Checking if flush is needed on ${topicPartition.topic} flush interval ${log.config.flushMs}" +
s" last flushed ${log.lastFlushTime} time since last flush: $timeSinceLastFlush")
if(timeSinceLastFlush >= log.config.flushMs)
log.flush()
log.flush(false)
} catch {
case e: Throwable =>
error(s"Error flushing topic ${topicPartition.topic}", e)
Expand Down
34 changes: 25 additions & 9 deletions core/src/main/scala/kafka/log/UnifiedLog.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

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.

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.

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 will run a benchmark next week.

@kowshik kowshik Dec 2, 2021

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.

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

@hachikuji hachikuji Dec 4, 2021

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.

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

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.

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.

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.

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

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.

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

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.

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.

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.

The comment is inaccurate. The recovery point is always offset.

*
* @param offset The offset to flush up to (non-inclusive); the new recovery point

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.

Should we get rid of "(non-inclusive)"?

* @param includingOffset Whether the flush includes the provided offset.
*/
private def flush(offset: Long, includingOffset: Boolean): Unit = {

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.

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

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.

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()}, " +

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.

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)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/kafka/raft/KafkaMetadataLog.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {

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.

Should inclusive be renamed to forceFlushActiveSegment?

log.flush(inclusive)
}

override def lastFlushedOffset(): Long = {
Expand Down
48 changes: 45 additions & 3 deletions core/src/test/scala/unit/kafka/log/LogLoaderTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)

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.

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

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.

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()
}
}
4 changes: 2 additions & 2 deletions core/src/test/scala/unit/kafka/log/LogManagerTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ class LogManagerTest {
for (_ <- 0 until 50)
log.appendAsLeader(TestUtils.singletonRecords("test".getBytes()), leaderEpoch = 0)

log.flush()
log.flush(false)
}

logManager.checkpointLogRecoveryOffsets()
Expand Down Expand Up @@ -484,7 +484,7 @@ class LogManagerTest {
allLogs.foreach { log =>
for (_ <- 0 until 50)
log.appendAsLeader(TestUtils.singletonRecords("test".getBytes), leaderEpoch = 0)
log.flush()
log.flush(false)
}

logManager.checkpointRecoveryOffsetsInDir(logDir)
Expand Down
21 changes: 18 additions & 3 deletions core/src/test/scala/unit/kafka/log/UnifiedLogTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -1630,6 +1630,21 @@ class UnifiedLogTest {
assertThrows(classOf[OffsetOutOfRangeException], () => LogTestUtils.readLog(log, 1026, 1000))
}

@Test
def testFlushingEmptyActiveSegments(): Unit = {

@kowshik kowshik Nov 29, 2021

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.

Is it possible to add another test in LogLoaderTest, where, we clean shutdown a Log with empty active segment and reload the log again. Then, we should expect that the recovery code path doesn't declare the last segment to be corrupted.

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.

Done with other comments. Will work on this one next week.

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.

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)

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.

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.
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions core/src/test/scala/unit/kafka/server/LogOffsetTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class LogOffsetTest extends BaseRequestTest {

for (_ <- 0 until 20)
log.appendAsLeader(TestUtils.singletonRecords(value = Integer.toString(42).getBytes()), leaderEpoch = 0)
log.flush()
log.flush(false)

log.updateHighWatermark(log.logEndOffset)
log.maybeIncrementLogStartOffset(3, ClientRecordDeletion)
Expand All @@ -94,7 +94,7 @@ class LogOffsetTest extends BaseRequestTest {

for (timestamp <- 0 until 20)
log.appendAsLeader(TestUtils.singletonRecords(value = Integer.toString(42).getBytes(), timestamp = timestamp.toLong), leaderEpoch = 0)
log.flush()
log.flush(false)

log.updateHighWatermark(log.logEndOffset)

Expand All @@ -117,7 +117,7 @@ class LogOffsetTest extends BaseRequestTest {

for (timestamp <- List(0L, 1L, 2L, 3L, 4L, 6L, 5L))
log.appendAsLeader(TestUtils.singletonRecords(value = Integer.toString(42).getBytes(), timestamp = timestamp), leaderEpoch = 0)
log.flush()
log.flush(false)

log.updateHighWatermark(log.logEndOffset)

Expand All @@ -139,7 +139,7 @@ class LogOffsetTest extends BaseRequestTest {

for (_ <- 0 until 20)
log.appendAsLeader(TestUtils.singletonRecords(value = Integer.toString(42).getBytes()), leaderEpoch = 0)
log.flush()
log.flush(false)

val offsets = log.legacyFetchOffsetsBefore(ListOffsetsRequest.LATEST_TIMESTAMP, 15)
assertEquals(Seq(20L, 18L, 16L, 14L, 12L, 10L, 8L, 6L, 4L, 2L, 0L), offsets)
Expand Down Expand Up @@ -210,7 +210,7 @@ class LogOffsetTest extends BaseRequestTest {

for (_ <- 0 until 20)
log.appendAsLeader(TestUtils.singletonRecords(value = Integer.toString(42).getBytes()), leaderEpoch = 0)
log.flush()
log.flush(false)

val now = time.milliseconds + 30000 // pretend it is the future to avoid race conditions with the fs

Expand Down Expand Up @@ -238,7 +238,7 @@ class LogOffsetTest extends BaseRequestTest {
val log = logManager.getOrCreateLog(topicPartition, topicId = None)
for (_ <- 0 until 20)
log.appendAsLeader(TestUtils.singletonRecords(value = Integer.toString(42).getBytes()), leaderEpoch = 0)
log.flush()
log.flush(false)

val offsets = log.legacyFetchOffsetsBefore(ListOffsetsRequest.EARLIEST_TIMESTAMP, 10)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ class EpochDrivenReplicationProtocolAcceptanceTest extends QuorumTestHarness wit

private def getLogFile(broker: KafkaServer, partition: Int): File = {
val log: UnifiedLog = getLog(broker, partition)
log.flush()
log.flush(false)
log.dir.listFiles.filter(_.getName.endsWith(".log"))(0)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class DumpLogSegmentsTest {
leaderEpoch = 0)
}
// Flush, but don't close so that the indexes are not trimmed and contain some zero entries
log.flush()
log.flush(false)
}

@AfterEach
Expand Down Expand Up @@ -242,7 +242,7 @@ class DumpLogSegmentsTest {
new SimpleRecord(null, buf.array)
}).toArray
log.appendAsLeader(MemoryRecords.withRecords(CompressionType.NONE, records:_*), leaderEpoch = 1)
log.flush()
log.flush(false)

var output = runDumpLogSegments(Array("--cluster-metadata-decoder", "false", "--files", logFilePath))
assert(output.contains("TOPIC_RECORD"))
Expand Down
5 changes: 3 additions & 2 deletions raft/src/main/java/org/apache/kafka/raft/KafkaRaftClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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

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.

Yes. Looking at the code, we don't need to have segments to determine the log end offset. When loading the KafkaMetadataLog, KRaft truncateFullyAndStartAt using the latest snapshot if the log's end offset (0) is less than the latest snapshot's end offset. As @hachikuji mentioned, we only delete inactive segments if there is a snapshot that covers the largest offset in the segment.

}

private boolean maybeTransitionToLeader(CandidateState state, long currentTimeMs) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2362,6 +2362,7 @@ public Optional<SnapshotWriter<T>> createSnapshot(

@Override
public void close() {
log.flush(true);
if (kafkaRaftMetrics != null) {
kafkaRaftMetrics.close();
}
Expand Down
4 changes: 3 additions & 1 deletion raft/src/main/java/org/apache/kafka/raft/ReplicatedLog.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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 use forceFlushActiveSegment in UnifiedLog.flush(). Should we be consistent with the name?


/**
* Possibly perform cleaning of snapshots and logs
Expand Down
4 changes: 2 additions & 2 deletions raft/src/test/java/org/apache/kafka/raft/MockLog.java
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ snapshotId.offset > endOffset().offset)) {
epochStartOffsets.clear();
snapshots.headMap(snapshotId, false).clear();
updateHighWatermark(new LogOffsetMetadata(snapshotId.offset));
flush();
flush(false);

truncated.set(true);
}
Expand Down Expand Up @@ -328,7 +328,7 @@ private LogAppendInfo append(Records records, OptionalInt epoch) {
}

@Override
public void flush() {
public void flush(boolean inclusive) {
lastFlushedOffset = endOffset().offset;
}

Expand Down
2 changes: 1 addition & 1 deletion raft/src/test/java/org/apache/kafka/raft/MockLogTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ public void testMonotonicEpochStartOffset() {
public void testUnflushedRecordsLostAfterReopen() {
appendBatch(5, 1);
appendBatch(10, 2);
log.flush();
log.flush(false);

appendBatch(5, 3);
appendBatch(10, 4);
Expand Down