Skip to content

Commit 4012b26

Browse files
giuseppelillogqmelo
authored andcommitted
fix(inkless:switch): Fix stale HW on switched leader promotion (#660)
* fix(inkless:switch): Fix stale HW on switched leader promotion Reconcile already-switched leaders after makeLeader so a promoted replica with a stale checkpointed HW advances to the committed seal. Truncate any local classic tail above the seal, and fence leaders whose local LEO is below the seal to avoid serving an incomplete prefix. Keep follower truncation limited to the seal-commit delta. Add a deterministic system test for the stale-HW promotion regression. * Skip reconciliation of a partition if log is not found
1 parent bd0101c commit 4012b26

4 files changed

Lines changed: 551 additions & 84 deletions

File tree

core/src/main/scala/kafka/server/ReplicaManager.scala

Lines changed: 87 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3299,22 +3299,73 @@ class ReplicaManager(val config: KafkaConfig,
32993299
}
33003300

33013301
/**
3302-
* Reconciles a partition whose classic-to-diskless seal was *just committed* in this delta
3303-
* (previous seal < 0, now >= 0); other transitions are left untouched. Records beyond the seal
3304-
* are uncommitted (the diskless log continues from the seal), so:
3305-
* - LEO > seal: truncate down to the seal (a follower/deposed leader that held un-replicated
3306-
* records past a seal a different broker committed at a lower HW);
3307-
* - LEO < seal and leader: fence offline. Unreachable on the clean path (the driving leader
3308-
* seals at its own HW == LEO; a cleanly-elected leader comes from the ISR with LEO >= seal),
3309-
* so it implies a corrupt classic prefix -- don't serve an incomplete prefix below the seal.
3302+
* Reconcile a leader whose classic-to-diskless seal has already been committed.
33103303
*
3311-
* Must run after makeLeader/makeFollower (so the log exists) and before any catch-up or
3312-
* consolidation fetcher starts (so they initialize from the reconciled LEO).
3304+
* A committed seal is the first diskless offset and the classic prefix [0, seal) must be fully
3305+
* present on any cleanly-elected leader. Once sealed, the local classic log cannot advance HW
3306+
* naturally, so a leader promoted with a stale checkpointed HW must restore HW to the seal.
3307+
*
3308+
* - LEO > seal: truncate down to the seal. Records at or above the seal are uncommitted classic
3309+
* tail; diskless owns that range.
3310+
* - LEO == seal and HW < seal: advance HW to the seal so consumers can cross into diskless.
3311+
* - LEO < seal: fence offline. The classic prefix is incomplete, so serving it would hide a
3312+
* hole in acknowledged data.
3313+
*
3314+
* Must run after makeLeader (so the log exists) and before any consolidation fetcher starts.
3315+
*/
3316+
private def maybeReconcileSwitchedLeader(tp: TopicPartition,
3317+
topicId: Uuid,
3318+
newRegistration: PartitionRegistration): Unit = {
3319+
val seal = newRegistration.classicToDisklessStartOffset
3320+
if (seal < 0) return
3321+
3322+
onlinePartition(tp).foreach { partition =>
3323+
partition.log match {
3324+
case Some(log) =>
3325+
try {
3326+
if (log.logEndOffset > seal) {
3327+
stateChangeLogger.info(s"Truncating switched partition $tp from LEO ${log.logEndOffset} " +
3328+
s"to classic-to-diskless start offset $seal")
3329+
// Seal is the classicToDisklessStartOffset, the first offset owned by diskless storage.
3330+
// truncateTo(seal) removes all entries with offset >= seal, leaving LEO = seal.
3331+
// The last classic record is at offset seal - 1.
3332+
partition.truncateTo(seal, isFuture = false)
3333+
}
3334+
if (partition.isLeader) {
3335+
if (log.logEndOffset >= seal && log.highWatermark < seal) {
3336+
log.maybeUpdateHighWatermark(seal)
3337+
stateChangeLogger.info(s"Stale high watermark detected: advanced high watermark to seal offset $seal for " +
3338+
s"switched leader partition $tp")
3339+
} else if (log.logEndOffset < seal) {
3340+
// This is unreachable in normal operation
3341+
stateChangeLogger.error(s"Leader partition $tp has LEO ${log.logEndOffset} below " +
3342+
s"classic-to-diskless start offset $seal; cannot catch up from another replica. " +
3343+
s"Marking the partition offline as its local log is corrupt below the committed seal.")
3344+
markPartitionOffline(tp)
3345+
}
3346+
}
3347+
} catch {
3348+
case e: KafkaStorageException =>
3349+
stateChangeLogger.error(s"Unable to reconcile switched partition $tp " +
3350+
s"with topic ID $topicId due to a storage error ${e.getMessage}", e)
3351+
markPartitionOffline(tp)
3352+
}
3353+
case None =>
3354+
stateChangeLogger.warn(s"Skipping switched partition reconciliation for $tp " +
3355+
s"with topic ID $topicId because the local log is not available")
3356+
}
3357+
}
3358+
}
3359+
3360+
/**
3361+
* Followers truncate only when the seal is committed in the current metadata delta. A switched
3362+
* follower may later grow past the seal as part of normal consolidation/catch-up flows; unlike a
3363+
* promoted leader, a follower should not be re-truncated on every unrelated metadata change.
33133364
*/
3314-
private def maybeTruncateNewlySwitchedPartition(tp: TopicPartition,
3315-
topicId: Uuid,
3316-
newRegistration: PartitionRegistration,
3317-
delta: TopicsDelta): Unit = {
3365+
private def maybeTruncateNewlySwitchedFollower(tp: TopicPartition,
3366+
topicId: Uuid,
3367+
newRegistration: PartitionRegistration,
3368+
delta: TopicsDelta): Unit = {
33183369
val seal = newRegistration.classicToDisklessStartOffset
33193370
if (seal < 0) return
33203371

@@ -3325,27 +3376,23 @@ class ReplicaManager(val config: KafkaConfig,
33253376
if (!sealJustCommitted) return
33263377

33273378
onlinePartition(tp).foreach { partition =>
3328-
try {
3329-
val log = partition.localLogOrException
3330-
if (log.logEndOffset > seal) {
3331-
stateChangeLogger.info(s"Truncating switched partition $tp from LEO ${log.logEndOffset} " +
3332-
s"to classic-to-diskless start offset $seal")
3333-
// Seal is the classicToDisklessStartOffset, the first offset owned by diskless storage.
3334-
// truncateTo(seal) removes all entries with offset >= seal, leaving LEO = seal.
3335-
// The last classic record is at offset seal - 1.
3336-
partition.truncateTo(seal, isFuture = false)
3337-
} else if (log.logEndOffset < seal && partition.isLeader) {
3338-
// This is unreachable in normal operation
3339-
stateChangeLogger.error(s"Leader partition $tp has LEO ${log.logEndOffset} below " +
3340-
s"classic-to-diskless start offset $seal; cannot catch up from another replica. " +
3341-
s"Marking the partition offline as its local log is corrupt below the committed seal.")
3342-
markPartitionOffline(tp)
3343-
}
3344-
} catch {
3345-
case e: KafkaStorageException =>
3346-
stateChangeLogger.error(s"Unable to reconcile switched partition $tp " +
3347-
s"with topic ID $topicId due to a storage error ${e.getMessage}", e)
3348-
markPartitionOffline(tp)
3379+
partition.log match {
3380+
case Some(log) =>
3381+
try {
3382+
if (log.logEndOffset > seal) {
3383+
stateChangeLogger.info(s"Truncating switched follower partition $tp from LEO ${log.logEndOffset} " +
3384+
s"to classic-to-diskless start offset $seal")
3385+
partition.truncateTo(seal, isFuture = false)
3386+
}
3387+
} catch {
3388+
case e: KafkaStorageException =>
3389+
stateChangeLogger.error(s"Unable to reconcile switched follower partition $tp " +
3390+
s"with topic ID $topicId due to a storage error ${e.getMessage}", e)
3391+
markPartitionOffline(tp)
3392+
}
3393+
case None =>
3394+
stateChangeLogger.warn(s"Skipping switched follower partition reconciliation for $tp " +
3395+
s"with topic ID $topicId because the local log is not available")
33493396
}
33503397
}
33513398
}
@@ -3526,16 +3573,6 @@ class ReplicaManager(val config: KafkaConfig,
35263573
// local log; seal() here just marks the partition sealed for the HW restore below.
35273574
partition.makeLeader(info.partition, isNew, offsetCheckpoints, Some(info.topicId), partitionAssignedDirectoryId)
35283575
partition.seal()
3529-
// makeLeader reloads HW from the on-disk checkpoint, which may be stale
3530-
// (unclean shutdown, or checkpoint interval hadn't fired). Since the
3531-
// partition is sealed, no produces or follower fetches can ever advance
3532-
// HW naturally — restore it to the seal offset so consumers can read.
3533-
val sealOffset = _inklessMetadataView.getClassicToDisklessStartOffset(tp)
3534-
if (sealOffset >= 0 && partition.localLogOrException.highWatermark < sealOffset) {
3535-
partition.localLogOrException.maybeUpdateHighWatermark(sealOffset)
3536-
stateChangeLogger.info(s"Advanced high watermark to seal offset $sealOffset for " +
3537-
s"switched leader partition $tp after restart")
3538-
}
35393576
changedPartitions.add(partition)
35403577
} catch {
35413578
case e: KafkaStorageException =>
@@ -3547,13 +3584,11 @@ class ReplicaManager(val config: KafkaConfig,
35473584
}
35483585
}
35493586

3550-
// Truncate any partition whose seal was just committed in this delta and whose local log still
3551-
// runs past it (uncommitted records beyond the committed seal). This must happen after
3552-
// makeLeader (so the log exists) and before the consolidation fetchers below start, so
3553-
// consolidation initializes against the truncated LEO. For the broker that drove the switch
3554-
// this is a no-op (it seals at its own HW == LEO); it is kept here as defense.
3587+
// Reconcile switched leaders after makeLeader so promoted leaders with stale checkpointed HW
3588+
// are immediately readable up to the seal, and corrupt/incomplete prefixes are fenced before
3589+
// serving reads.
35553590
localLeaders.foreachEntry { (tp, info) =>
3556-
maybeTruncateNewlySwitchedPartition(tp, info.topicId, info.partition, delta)
3591+
maybeReconcileSwitchedLeader(tp, info.topicId, info.partition)
35573592
}
35583593

35593594
if (consolidatingDisklessPartitionsToStartFetching.nonEmpty) {
@@ -3683,7 +3718,7 @@ class ReplicaManager(val config: KafkaConfig,
36833718
// truncated LEO. The fetch decisions above key off the high watermark (which never exceeds
36843719
// the seal), so truncating here does not change which partitions need a catch-up fetcher.
36853720
localFollowers.foreachEntry { (tp, info) =>
3686-
maybeTruncateNewlySwitchedPartition(tp, info.topicId, info.partition, delta)
3721+
maybeTruncateNewlySwitchedFollower(tp, info.topicId, info.partition, delta)
36873722
}
36883723

36893724
if (partitionsToStartFetching.nonEmpty) {

core/src/test/scala/unit/kafka/server/DisklessSwitchInvariantsTest.scala

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import org.apache.kafka.common.metrics.Metrics
2929
import org.apache.kafka.common.record.{MemoryRecords, SimpleRecord}
3030
import org.apache.kafka.common.utils.Time
3131
import org.apache.kafka.image._
32+
import org.apache.kafka.metadata.{InitDisklessLogFields, PartitionRegistration}
3233
import org.apache.kafka.server.common.{KRaftVersion, MetadataVersion}
3334
import org.apache.kafka.server.config.ServerConfigs
3435
import org.apache.kafka.server.PartitionFetchState
@@ -84,18 +85,21 @@ class DisklessSwitchInvariantsTest {
8485

8586
// --- Invariant: Sealed leader HW >= seal offset ---
8687

87-
@ParameterizedTest(name = "leader checkpoint hw={0}, seal={1}")
88+
@ParameterizedTest(name = "leader checkpoint hw={0}, seal={1}, leo={2}")
8889
@MethodSource(Array("sealedLeaderScenarios"))
89-
def testSealedLeaderHwInvariant(checkpointHw: Long, sealOffset: Long, expectedHw: Long): Unit = {
90+
def testSealedLeaderHwInvariant(
91+
checkpointHw: Long,
92+
sealOffset: Long,
93+
localLeo: Long,
94+
expectedHw: Long,
95+
expectedLeo: Long): Unit = {
9096
val topicName = "switched-topic"
9197
val topicId = Uuid.randomUuid()
9298
val tp = new TopicPartition(topicName, 0)
93-
// For committed seals, LEO == seal (fully replicated). For pending/none, use a fixed LEO.
94-
val leo = if (sealOffset >= 0) sealOffset else 10L
9599

96100
replicaManager = createReplicaManager(List(topicName))
97101
val log = replicaManager.logManager.getOrCreateLog(tp, isNew = true, topicId = Optional.of(topicId))
98-
populateLocalLogAtLeoAndCheckpointedHwm(replicaManager, tp, log, leo, checkpointHw)
102+
populateLocalLogAtLeoAndCheckpointedHwm(replicaManager, tp, log, localLeo, checkpointHw)
99103

100104
when(replicaManager.inklessMetadataView().getClassicToDisklessStartOffset(tp))
101105
.thenReturn(sealOffset)
@@ -110,6 +114,8 @@ class DisklessSwitchInvariantsTest {
110114
assertEquals(expectedHw, partition.localLogOrException.highWatermark,
111115
s"Sealed leader invariant: HW must be >= sealOffset ($sealOffset) " +
112116
s"regardless of checkpoint state ($checkpointHw)")
117+
assertEquals(expectedLeo, partition.localLogOrException.logEndOffset,
118+
s"Sealed leader invariant: LEO must be reconciled to expected offset $expectedLeo")
113119
}
114120

115121
// --- Invariant: Sealed follower with HW < seal triggers catch-up fetcher ---
@@ -156,33 +162,44 @@ class DisklessSwitchInvariantsTest {
156162
private def leaderDelta(topicName: String, topicId: Uuid): TopicsDelta = {
157163
val delta = new TopicsDelta(TopicsImage.EMPTY)
158164
delta.replay(new TopicRecord().setName(topicName).setTopicId(topicId))
159-
delta.replay(new PartitionRecord()
165+
val partitionRecord = new PartitionRecord()
160166
.setPartitionId(0)
161167
.setTopicId(topicId)
162168
.setReplicas(util.Arrays.asList(brokerId, brokerId + 1))
163169
.setIsr(util.Arrays.asList(brokerId, brokerId + 1))
164170
.setLeader(brokerId)
165171
.setLeaderEpoch(0)
166172
.setPartitionEpoch(0)
167-
)
173+
addClassicToDisklessStartOffsetIfPresent(topicName, partitionRecord)
174+
delta.replay(partitionRecord)
168175
delta
169176
}
170177

171178
private def followerDelta(topicName: String, topicId: Uuid, leaderId: Int): TopicsDelta = {
172179
val delta = new TopicsDelta(TopicsImage.EMPTY)
173180
delta.replay(new TopicRecord().setName(topicName).setTopicId(topicId))
174-
delta.replay(new PartitionRecord()
181+
val partitionRecord = new PartitionRecord()
175182
.setPartitionId(0)
176183
.setTopicId(topicId)
177184
.setReplicas(util.Arrays.asList(brokerId, leaderId))
178185
.setIsr(util.Arrays.asList(brokerId, leaderId))
179186
.setLeader(leaderId)
180187
.setLeaderEpoch(0)
181188
.setPartitionEpoch(0)
182-
)
189+
addClassicToDisklessStartOffsetIfPresent(topicName, partitionRecord)
190+
delta.replay(partitionRecord)
183191
delta
184192
}
185193

194+
private def addClassicToDisklessStartOffsetIfPresent(topicName: String, partitionRecord: PartitionRecord): Unit = {
195+
val sealOffset = replicaManager.inklessMetadataView().getClassicToDisklessStartOffset(
196+
new TopicPartition(topicName, partitionRecord.partitionId()))
197+
if (sealOffset != PartitionRegistration.NO_CLASSIC_TO_DISKLESS_START_OFFSET) {
198+
partitionRecord.unknownTaggedFields().add(
199+
InitDisklessLogFields.encodeClassicToDisklessStartOffset(sealOffset))
200+
}
201+
}
202+
186203
private def populateLocalLogAtLeoAndCheckpointedHwm(
187204
replicaManager: ReplicaManager, tp: TopicPartition,
188205
log: UnifiedLog, leo: Long, hw: Long): Unit = {
@@ -263,28 +280,30 @@ object DisklessSwitchInvariantsTest {
263280
import org.apache.kafka.metadata.PartitionRegistration._
264281

265282
def sealedLeaderScenarios(): java.util.stream.Stream[Arguments] = {
266-
// (checkpointHw, sealOffset, expectedHwAfterApplyDelta)
283+
// (checkpointHw, sealOffset, localLeo, expectedHwAfterApplyDelta, expectedLeoAfterApplyDelta)
267284
// Committed seal (>= 0): HW is always advanced to sealOffset regardless of checkpoint.
268285
// The post-restart path (getOrCreatePartition + makeLeader) creates a new Partition object
269286
// that re-initializes HW from the checkpoint via LazyOffsetCheckpoints. However, since the
270287
// log already exists, LogManager returns it without re-reading the checkpoint. The seal
271288
// offset advancement in applyLocalLeadersDelta (sealOffset >= 0 && HW < sealOffset guard)
272-
// compensates by forcing HW = sealOffset.
289+
// compensates by forcing HW = sealOffset, even when an uncommitted tail must first be
290+
// truncated from LEO > seal to LEO == seal.
273291
java.util.stream.Stream.of(
274-
Arguments.of(0L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // unclean shutdown
275-
Arguments.of(5L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // partial flush
276-
Arguments.of(10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // clean shutdown
292+
Arguments.of(0L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // unclean shutdown
293+
Arguments.of(5L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // partial flush
294+
Arguments.of(5L: java.lang.Long, 10L: java.lang.Long, 13L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // stale HW with uncommitted tail
295+
Arguments.of(10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long, 10L: java.lang.Long), // clean shutdown
277296
// Pending seal (-2): partition is sealed. The HW is NOT advanced because the seal
278297
// offset is not yet committed. The post-restart path does not restore HW from
279298
// checkpoint for pending seals — the pending fetcher (follower side) will eventually
280299
// propagate the correct HW when the seal commits.
281-
Arguments.of(0L: java.lang.Long, CLASSIC_TO_DISKLESS_SWITCH_PENDING: java.lang.Long, 0L: java.lang.Long),
282-
Arguments.of(5L: java.lang.Long, CLASSIC_TO_DISKLESS_SWITCH_PENDING: java.lang.Long, 0L: java.lang.Long),
300+
Arguments.of(0L: java.lang.Long, CLASSIC_TO_DISKLESS_SWITCH_PENDING: java.lang.Long, 10L: java.lang.Long, 0L: java.lang.Long, 10L: java.lang.Long),
301+
Arguments.of(5L: java.lang.Long, CLASSIC_TO_DISKLESS_SWITCH_PENDING: java.lang.Long, 10L: java.lang.Long, 0L: java.lang.Long, 10L: java.lang.Long),
283302
// No switch (-1): not realistic today but documents defensive behavior of the
284303
// post-restart path which seals unconditionally. Relevant if diskless topics
285304
// eventually use local logs as a read cache.
286-
Arguments.of(0L: java.lang.Long, NO_CLASSIC_TO_DISKLESS_START_OFFSET: java.lang.Long, 0L: java.lang.Long),
287-
Arguments.of(5L: java.lang.Long, NO_CLASSIC_TO_DISKLESS_START_OFFSET: java.lang.Long, 0L: java.lang.Long),
305+
Arguments.of(0L: java.lang.Long, NO_CLASSIC_TO_DISKLESS_START_OFFSET: java.lang.Long, 10L: java.lang.Long, 0L: java.lang.Long, 10L: java.lang.Long),
306+
Arguments.of(5L: java.lang.Long, NO_CLASSIC_TO_DISKLESS_START_OFFSET: java.lang.Long, 10L: java.lang.Long, 0L: java.lang.Long, 10L: java.lang.Long),
288307
)
289308
}
290309

0 commit comments

Comments
 (0)