@@ -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) {
0 commit comments