From f3346c2ddd87958bf159168c19dc69fd6b7ee534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9C=A4=EC=84=AD?= <62834176+lh0156@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:42:00 +0900 Subject: [PATCH] KAFKA-19996: Avoid premature ISR expansion under min ISR Require a follower to satisfy the existing caught-up check before expanding the ISR when the partition is under min ISR. This prevents a follower that only reached a stale high watermark from repeatedly entering and leaving the ISR, while preserving the existing leader-epoch expansion behavior outside that state. Tests: ./gradlew :core:test --tests kafka.cluster.PartitionTest Generated-by: OpenAI Codex (GPT-5) --- .../main/scala/kafka/cluster/Partition.scala | 16 ++++-- .../unit/kafka/cluster/PartitionTest.scala | 57 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/core/src/main/scala/kafka/cluster/Partition.scala b/core/src/main/scala/kafka/cluster/Partition.scala index 96bc3ddebf48e..225571642b7bd 100755 --- a/core/src/main/scala/kafka/cluster/Partition.scala +++ b/core/src/main/scala/kafka/cluster/Partition.scala @@ -867,9 +867,10 @@ class Partition(val topicPartition: TopicPartition, * leader's HW and LEO, the replica may become the leader before it fetches the committed data * and the data will be lost. * - * Technically, a replica shouldn't be in ISR if it hasn't caught up for longer than replicaLagTimeMaxMs, - * even if its log end offset is >= HW. However, to be consistent with how the follower determines - * whether a replica is in-sync, we only check HW. + * When the partition is under min ISR, the HW may be behind the leader's LEO. In that case, a + * replica is not considered caught up just because it has reached the HW. The same caught-up + * check used when shrinking the ISR is also required to avoid repeatedly expanding and shrinking + * the ISR for a follower that remains behind the leader's LEO. * * This function can be triggered when a replica's LEO has incremented. */ @@ -906,8 +907,13 @@ class Partition(val topicPartition: TopicPartition, private def isFollowerInSync(followerReplica: Replica): Boolean = { leaderLogIfLocal.exists { leaderLog => - val followerEndOffset = followerReplica.stateSnapshot.logEndOffset - followerEndOffset >= leaderLog.highWatermark && leaderEpochStartOffsetOpt.exists(followerEndOffset >= _) + val followerState = followerReplica.stateSnapshot + val followerEndOffset = followerState.logEndOffset + val followerIsCaughtUp = !isUnderMinIsr || + followerState.isCaughtUp(leaderLog.logEndOffset, time.milliseconds(), replicaLagTimeMaxMs) + followerEndOffset >= leaderLog.highWatermark && + leaderEpochStartOffsetOpt.exists(followerEndOffset >= _) && + followerIsCaughtUp } } diff --git a/core/src/test/scala/unit/kafka/cluster/PartitionTest.scala b/core/src/test/scala/unit/kafka/cluster/PartitionTest.scala index 40f385e6a229e..a474f3f3b3de4 100644 --- a/core/src/test/scala/unit/kafka/cluster/PartitionTest.scala +++ b/core/src/test/scala/unit/kafka/cluster/PartitionTest.scala @@ -2283,6 +2283,63 @@ class PartitionTest extends AbstractPartitionTest { assertEquals(log.logEndOffset, partition.localLogOrException.highWatermark) } + @Test + def testIsrNotExpandedWhenFollowerHasNotCaughtUpToLeaderLogEnd(): Unit = { + configRepository.setTopicConfig(topicPartition.topic, TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, "3") + val leaderEpoch = 5 + val log = logManager.getOrCreateLog(topicPartition, topicId.toJava) + + val remoteBrokerId = brokerId + 1 + val replicas = Array(brokerId, remoteBrokerId, brokerId + 2) + val isr = Array(brokerId) + addBrokerEpochToMockMetadataCache(metadataCache, replicas) + + val partition = new Partition( + topicPartition, + replicaLagTimeMaxMs = ReplicationConfigs.REPLICA_LAG_TIME_MAX_MS_DEFAULT, + localBrokerId = brokerId, + () => defaultBrokerEpoch(brokerId), + time, + alterPartitionListener, + delayedOperations, + metadataCache, + logManager, + alterPartitionManager + ) + partition.createLogIfNotExists(isNew = false, isFutureReplica = false, offsetCheckpoints, topicId) + + val partitionRegistration = new PartitionRegistration.Builder() + .setLeader(brokerId) + .setLeaderRecoveryState(LeaderRecoveryState.RECOVERED) + .setLeaderEpoch(leaderEpoch) + .setIsr(isr) + .setPartitionEpoch(1) + .setReplicas(replicas) + .setDirectories(DirectoryId.unassignedArray(replicas.length)) + .build() + assertTrue(partition.makeLeader(partitionRegistration, isNew = true, offsetCheckpoints, topicId), + "Expected become leader transition to succeed") + seedLogData(log, numRecords = 10, leaderEpoch = leaderEpoch) + time.sleep(partition.replicaLagTimeMaxMs + 1) + + // The partition is under min ISR, so its HWM remains at zero. Reaching the HWM alone + // must not add a follower which is still behind the leader's log end offset. + fetchFollower(partition, replicaId = remoteBrokerId, fetchOffset = 1L) + assertReplicaState(partition, remoteBrokerId, + lastCaughtUpTimeMs = 0L, + logStartOffset = 0L, + logEndOffset = 1L + ) + assertEquals(util.Set.of(brokerId), partition.partitionState.isr) + assertEquals(0, alterPartitionManager.isrUpdates.size) + + // Once the follower catches up to the leader's log end, it can be added to the ISR. + fetchFollower(partition, replicaId = remoteBrokerId, fetchOffset = log.logEndOffset) + assertEquals(1, alterPartitionManager.isrUpdates.size) + assertEquals(util.Set.of[Integer](brokerId, remoteBrokerId), + alterPartitionManager.isrUpdates.peek().leaderAndIsr.isr) + } + @Test def testAlterIsrLeaderAndIsrRace(): Unit = { val log = logManager.getOrCreateLog(topicPartition, topicId.toJava)