namalzi

← All posts

Digging into Kafka's Source 9: Replication — The ISR, the High Watermark and the Wait Behind acks=all

Kafka · Published

A follower broker's ReplicaFetcherThread sends Fetch requests to the leader and copies its log, and the leader learns each follower's position from the offset in those Fetch requests and raises the high watermark. We follow, in Partition.scala and AbstractFetcherThread.scala, the rule for dropping out of the ISR (replica.lag.time.max.ms and lastCaughtUpTimeMs), the AlterPartition request that tells the controller about ISR changes, the condition an acks=all request waits for as a DelayedProduce in the purgatory, the places where min.insync.replicas rejects a request, and how a follower truncates a diverged log with leader epochs after the leader changes — and we check it all with examples that freeze and kill brokers in a three-broker cluster.

What this post covers

This is the ninth post in the "Digging into Kafka's Source" series. In part 6 we saw how the leader broker writes the records of a Produce request to its own log, in part 7 how that log is laid out on disk, and in part 8 how Fetch requests read it back. At the end of part 8 we said that "consumers and followers use the same Fetch"; this post is the follower's side of that story. It follows a record written on the leader as it moves to other brokers, and how that fact travels back to the leader to decide both the producer's acks=all response and how far consumers can read.

Where this post zooms in on the part 2 map. A producer's acks=all request passes through KafkaApis to the Partition on the leader broker; the Partition writes to UnifiedLog and makes the request wait as a DelayedProduce. The ReplicaFetcherThread on a follower broker sends Fetch requests to the leader's KafkaApis and writes what it receives to its own UnifiedLog. When the ISR changes, the leader's Partition sends AlterPartition to the controller. A close-up of area D (partitions and replication) on the big map from part 2. The blue boxes are the main characters of this post, the yellow boxes hold state, and the grey boxes belong to the posts before and after it.

There are four questions to answer.

Item Value
Project Apache Kafka
Version 4.5.0-SNAPSHOT (trunk)
Commit ab1e816411 (2026-10-24)
Main classes kafka.cluster.Partition (Scala), kafka.server.ReplicaFetcherManager·AbstractFetcherThread·ReplicaFetcherThread·RemoteLeaderEndPoint·ReplicaManager (Scala), org.apache.kafka.server.replica.Replica·ReplicaState, org.apache.kafka.server.partition.DefaultAlterPartitionManager, org.apache.kafka.server.purgatory.DelayedProduce, org.apache.kafka.storage.internals.log.UnifiedLog·epoch.LeaderEpochFileCache (Java)

All line numbers in this post refer to this commit. Part 1 shows how to check out the same commit and build the Docker image used for the examples.

Partition.scala, the heart of replication, is 1,856 lines; AbstractFetcherThread.scala is 873 and ReplicaManager.scala is 2,577. Moving logs between directories (JBOD), tiered storage and reassignment all live in the same files. This post picks only the main path a newcomer can follow; what it leaves out is listed by name and location in "What this post leaves out" at the end.

Words to know first

Replica, leader, follower: one partition is placed on several brokers as copies (replicas), and one of them, the leader, takes the writes and reads. The other replicas, the followers, fetch the leader's log and copy it (part 1). The controller decides who the leader is and spreads that through the metadata log (part 2, part 10).

LEO (log end offset): the offset the next record written to that replica's log will get. A log holding offsets 0–9 has an LEO of 10. Every replica has its own.

High watermark (HW): the boundary offset meaning "everything before this is held by the whole ISR". Consumers can only read records before the HW: with an HW of 10 they see up to offset 9. The leader computes it, and followers copy down the value the leader tells them.

ISR (in-sync replicas): the set of replicas that are "keeping up well enough" with the leader. The leader itself is in it. It's the Isr: 1,2,3 in kafka-topics.sh --describe. Section 4 of this post reads the source for what "well enough" means exactly.

Leader epoch: a generation number that goes up by one every time the partition's leader changes (in some cases it goes up even when the leader stays, such as when a reassignment removes replicas — PartitionChangeBuilder.triggerLeaderEpochBumpForReplicaReassignmentIfNeeded()). The leader stamps it on every record batch it writes (the partitionLeaderEpoch field of the batch header from part 6). After a leader change it tells you "which record was written under which leader", which is how diverged logs are reconciled (section 6). Separately there is a partition epoch, which goes up whenever the partition's state — its ISR or leader — changes.

Purgatory and DelayedProduce: a waiting room where a response is held back until a condition is met. Where part 8's DelayedFetch waited "until enough data piles up", this post's DelayedProduce waits "until replication is done".

AlterPartition: the request a leader broker sends to the controller saying "please change this partition's ISR to this". The leader decides the ISR, but the controller confirms and records it.

1. The follower side — a ReplicaFetcherThread sends Fetch requests to the leader

When a broker receives metadata saying "you are a follower of this partition", ReplicaManager.applyLocalFollowersDelta() calls Partition.makeFollower() and attaches the partition to a fetcher (ReplicaManager.scala lines 2458–2563). The fetcher threads are created in ReplicaFetcherManager, a 59-line class.

  override def createFetcherThread(fetcherId: Int, sourceBroker: BrokerEndPoint): ReplicaFetcherThread = {
    val threadName = s"ReplicaFetcherThread-$fetcherId-${sourceBroker.id}"
    val logContext = new LogContext(s"[ReplicaFetcher replicaId=${brokerConfig.brokerId}, leaderId=${sourceBroker.id}, " +
      s"fetcherId=$fetcherId] ")
    val endpoint = new BrokerBlockingSender(sourceBroker, brokerConfig, metrics, time, fetcherId,
      s"broker-${brokerConfig.brokerId}-fetcher-$fetcherId", logContext)
    val fetchSessionHandler = new FetchSessionHandler(logContext, sourceBroker.id)
    val leader: LeaderEndPoint = new RemoteLeaderEndPoint(logContext.logPrefix, endpoint, fetchSessionHandler, brokerConfig,
      replicaManager, quotaManager, metadataVersionSupplier, brokerEpochSupplier)
    new ReplicaFetcherThread(threadName, leader, brokerConfig, failedPartitions, replicaManager,
      quotaManager, logContext.logPrefix)
  }

core/src/main/scala/kafka/server/ReplicaFetcherManager.scala lines 41–52

Reading the Scala — override def f(...): T = { ... } redefines a parent's method, and the last expression in the block (new ReplicaFetcherThread(...)) is the return value. s"...$x...${a.b}" drops values into a string (string interpolation). val is a variable you can't reassign.

The thread name is ReplicaFetcherThread-<fetcherId>-<leader broker id>. Notice there is no partition in the name. There is one fetcher thread per "leader broker × fetcherId", not one per partition. AbstractFetcherManager.addFetcherForPartitions() groups partitions by (leader broker, getFetcherId(partition)) to choose the thread, and getFetcherId() is abs(31 * hash of the topic name + partition number) % num.replica.fetchers (AbstractFetcherManager.scala lines 111–115 and 120–150). num.replica.fetchers defaults to 1 (ReplicationConfigs.java lines 95–96), so with default settings one fetcher thread per leader broker handles every partition fetched from that leader. Whether you follow 100 partitions led by broker 1 or just one, there is a single thread, ReplicaFetcherThread-0-1.

BrokerBlockingSender is a thin wrapper that creates one NetworkClient and one Selector from the clients module (at most one request in flight), sends a request and waits for the response (BrokerBlockingSender.scala lines 71–123). The same Selector the broker used on the receiving side in part 4 is used here on the sending side. A follower broker behaves like a client toward the leader.

The follower side. When a metadata change reaches ReplicaManager's applyLocalFollowersDelta(), ReplicaFetcherManager (num.replica.fetchers, default 1) keeps a ReplicaFetcherThread per leader broker. ReplicaFetcherThread-0-1 handles the partitions led by broker 1 and -0-3 the partitions led by broker 3. The thread keeps a PartitionFetchState (fetchOffset, lastFetchedEpoch) per partition, builds requests with RemoteLeaderEndPoint's buildFetch() and fetch(), and sends Fetch (replicaId=2, maxWait 500 ms) through BrokerBlockingSender to KafkaApis.handleFetchRequest() on leader broker 1. Drawn from broker 2's point of view. The blue boxes are the thread and the objects it calls; the yellow box is the per-partition state the thread holds.

One loop: truncate if needed, then send a Fetch

ReplicaFetcherThread inherits from ShutdownableThread and keeps calling doWork() until told to stop. The parent class's doWork() is two lines.

  override def doWork(): Unit = {
    maybeTruncate()
    maybeFetch()
  }

core/src/main/scala/kafka/server/AbstractFetcherThread.scala lines 110–113

Reading the Scala — : Unit is Java's void. A method with no arguments is called with parentheses, like maybeTruncate().

maybeTruncate() only handles partitions that need the "truncation" we'll see in section 6; usually it does nothing. maybeFetch() builds a request with leader.buildFetch(partitionStates.partitionStateMap) and sends it via processFetchRequest() (lines 115–134). If there is no partition to fetch, it waits on a condition variable for replica.fetch.backoff.ms. The subclass ReplicaFetcherThread adds one line: at the end of each loop it wakes the DelayedFetches of partitions whose high watermark went up (completeDelayedFetchRequests(), ReplicaFetcherThread.scala lines 107–110). That is for consumers that read from a follower ("fetch from the closest replica", left out of this post).

What goes into the request is in RemoteLeaderEndPoint.buildFetch().

          val logStartOffset = replicaManager.localLogOrException(topicPartition).logStartOffset
          val lastFetchedEpoch = fetchState.lastFetchedEpoch()
          builder.add(topicPartition, new FetchRequest.PartitionData(
            fetchState.topicId().orElse(Uuid.ZERO_UUID),
            fetchState.fetchOffset(),
            logStartOffset,
            fetchSize,
            Optional.of(fetchState.currentLeaderEpoch()),
            lastFetchedEpoch))

core/src/main/scala/kafka/server/RemoteLeaderEndPoint.scala lines 184–192

Five things are sent per partition: fetchOffset ("give me records from here"), its own log start offset, the maximum bytes, the leader epoch it currently knows (currentLeaderEpoch) and the leader epoch of the last batch it received (lastFetchedEpoch). The request as a whole is built with FetchRequest.Builder.forReplica(version, brokerId, brokerEpoch, maxWait, minBytes, ...) (lines 212–213). That forReplica is the only difference from a consumer's Fetch: the request carries the follower's broker id and broker epoch, so the leader receiving it knows "this is a follower's Fetch". maxWait and minBytes are replica.fetch.wait.max.ms (default 500) and replica.fetch.min.bytes (default 1) (ReplicationConfigs.java lines 74–80). The long polling from part 8, where the leader holds a Fetch for up to half a second when there is nothing new, applies to followers too.

Append the received records as they are, and note the HW the leader sent

When the response arrives, processFetchRequest() checks each partition's error code (NONE, OFFSET_OUT_OF_RANGE, FENCED_LEADER_EPOCH …, lines 279–388) and, if all is well, calls processPartitionData().

    // Append the leader's messages to the log
    val logAppendInfo = partition.appendRecordsToFollowerOrFutureReplica(records, isFuture = false, partitionLeaderEpoch)

    if (logTrace)
      trace("Follower has replica log end offset %d after appending %d bytes of messages for partition %s"
        .format(log.logEndOffset, records.sizeInBytes, topicPartition))
    val leaderLogStartOffset = partitionData.logStartOffset

    // For the follower replica, we do not need to keep its segment base offset and physical position.
    // These values will be computed upon becoming leader or handling a preferred read replica fetch.
    var maybeUpdateHighWatermarkMessage = s"but did not update replica high watermark"
    log.maybeUpdateHighWatermark(partitionData.highWatermark).ifPresent { newHighWatermark =>
      maybeUpdateHighWatermarkMessage = s"and updated replica high watermark to $newHighWatermark"
      partitionsWithNewHighWatermark += topicPartition
    }

core/src/main/scala/kafka/server/ReplicaFetcherThread.scala lines 132–146

Reading the Scala — var is a variable you can reassign. opt.ifPresent { x => ... } passes a Scala lambda (x => ...) to Java's Optional.ifPresent(x -> ...). buf += x adds one element to a mutable collection. Arguments can also be passed by name, like isFuture = false.

Two things happen. The first is the append. appendRecordsToFollowerOrFutureReplica() ends up calling UnifiedLog.appendAsFollower(), which calls append(..., AppendOrigin.REPLICATION, false, ...) — the third argument, assignOffsets, is false (UnifiedLog.java lines 1093–1103). The batches the leader stamped with offsets and a leader epoch in part 6 are appended by the follower byte for byte, without renumbering. This is also when the follower records the leader epoch stamped on each batch in its own epoch cache (assignEpochStartOffset(batch.partitionLeaderEpoch(), batch.baseOffset()), lines 1226–1229). That is why every replica's log ends up identical, down to the offsets.

The second is the HW. The response carries the leader's HW, and the follower adopts it with maybeUpdateHighWatermark(). But the updateHighWatermark() inside it clips the value to no more than its own LEO.

    public long updateHighWatermark(LogOffsetMetadata highWatermarkMetadata) throws IOException {
        LogOffsetMetadata endOffsetMetadata = localLog.logEndOffsetMetadata();
        LogOffsetMetadata newHighWatermarkMetadata = highWatermarkMetadata.messageOffset < logStartOffset
            ? new LogOffsetMetadata(logStartOffset)
            : highWatermarkMetadata.messageOffset >= endOffsetMetadata.messageOffset
                ? endOffsetMetadata
                : highWatermarkMetadata;

        updateHighWatermarkMetadata(newHighWatermarkMetadata);
        return newHighWatermarkMetadata.messageOffset;
    }

storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java lines 542–552

A follower's HW is min(the HW the leader sent, my LEO). The leader's HW is "where the whole ISR has the data", so it is usually at or below the follower's LEO anyway, but this guard keeps the follower from ever using an offset it doesn't have as its HW. The comment above the method also says "upper-bounded by the log end offset" (lines 583–591).

Example 1-1: the fetcher thread names

The examples in this post were run with Kafka 4.5.0-SNAPSHOT built as-is from the reference commit (ab1e816411), in a Docker container (Eclipse Temurin JDK 25). One controller (node 100) and three brokers (nodes 1, 2, 3) run in one container (ex/u09-isr-hw/run.sh), and we create a topic rep with its replicas pinned to 1:2:3. --replica-assignment 1:2:3 fixes even the order of the replica list, so the first one, broker 1, becomes the leader (the "first live replica" rule from part 2).

kafka-topics.sh --bootstrap-server $BOOTSTRAP --create --topic rep --replica-assignment 1:2:3
jstack $(kafka_pid 2) | grep -o '^"ReplicaFetcherThread[^"]*"'

These two lines are picked and shortened from run.sh (the real script prints each broker's jstack result joined onto one line). Here is the thread part of the output.

broker 2: "ReplicaFetcherThread-0-1" 
broker 3: "ReplicaFetcherThread-0-1" 
broker 1: (none: leader of rep-0)
broker 1: "broker-1-to-controller-alter-partition-channel-manager" 

Brokers 2 and 3, the followers, each have one ReplicaFetcherThread-0-1 fetching from the leader (broker 1); broker 1, the leader, has none. Instead broker 1 has a broker-1-to-controller-alter-partition-channel-manager thread that will send ISR changes to the controller (section 4).

2. The leader side — the Fetch offset itself says "I have everything up to here"

A follower never sends a separate "I got the records" acknowledgement. The fetchOffset of its next Fetch is that report. Asking for fetchOffset=11 means "I have up to 10 in my log", that is, "my LEO is 11". On the leader, ReplicaManager.readFromLog(), which handles Fetch requests, calls Partition.fetchRecords() for each partition, and that is where follower Fetches take their own branch.

    if (fetchParams.isFromFollower) {
      // Check that the request is from a valid replica before doing the read
      val (replica, logReadInfo) = inReadLock(leaderIsrUpdateLock, () => {
        val localLog = localLogWithEpochOrThrow(
          fetchPartitionData.currentLeaderEpoch,
          fetchParams.fetchOnlyLeader
        )
        val replica = followerReplicaOrThrow(
          fetchParams.replicaId,
          fetchPartitionData
        )
        val logReadInfo = readFromLocalLog(localLog)
        (replica, logReadInfo)
      })

      if (updateFetchState && !logReadInfo.divergingEpoch.isPresent) {
        updateFollowerFetchState(
          replica,
          followerFetchOffsetMetadata = logReadInfo.fetchedData.fetchOffsetMetadata,
          followerStartOffset = fetchPartitionData.logStartOffset,
          followerFetchTimeMs = fetchTimeMs,
          leaderEndOffset = logReadInfo.logEndOffset,
          fetchParams.replicaEpoch
        )
      }

core/src/main/scala/kafka/cluster/Partition.scala lines 1301–1325

Reading the Scala — val (a, b) = ... unpacks a two-value tuple into two variables at once. The last expression of the lambda () => { ... }, (replica, logReadInfo), is that tuple.

isFromFollower checks whether the request's replicaId is a valid broker id (0 or more) (FetchParams.java lines 67–69). The read itself is the same readFromLocalLog() a consumer gets, but how far it reads differs. FetchIsolation.of() picks LOG_END (to the end of the log) for followers and HIGH_WATERMARK (up to the HW) for consumers (FetchIsolation.java lines 31–39, the branch we saw in part 1). Followers must take records that aren't replicated yet, or replication would never progress; consumers must only see what is already replicated.

And only for a follower's Fetch does it go on to updateFollowerFetchState(). The updateFetchState in the condition is passed by ReplicaManager as !readFromPurgatory (ReplicaManager.scala lines 1838–1844), so the state is updated once, when the request first arrives and is read. When a long-polled request is read again later, the state is not touched.

What the leader keeps for each follower: Replica and lastCaughtUpTimeMs

The leader's Partition holds one Replica object per follower (remoteReplicasMap, line 181), and inside it swaps an immutable record, ReplicaState, through an AtomicReference. Its fields are the follower's log start and end offsets, the time of its last Fetch, the leader's LEO at that time, lastCaughtUpTimeMs, and the broker epoch carried in the Fetch (ReplicaState.java lines 32–39). The heart of Replica.updateFetchStateOrThrow(), called from updateFollowerFetchState(), is the three-way choice of lastCaughtUpTimeMs.

            long lastCaughtUpTime;
            if (followerFetchOffsetMetadata.messageOffset >= leaderEndOffset) {
                lastCaughtUpTime = Math.max(currentReplicaState.lastCaughtUpTimeMs(), followerFetchTimeMs);
            } else if (followerFetchOffsetMetadata.messageOffset >= currentReplicaState.lastFetchLeaderLogEndOffset()) {
                lastCaughtUpTime = Math.max(currentReplicaState.lastCaughtUpTimeMs(), currentReplicaState.lastFetchTimeMs());
            } else {
                lastCaughtUpTime = currentReplicaState.lastCaughtUpTimeMs();
            }

server/src/main/java/org/apache/kafka/server/replica/Replica.java lines 84–91

lastCaughtUpTimeMs is "the last time this follower had fully caught up with the leader". If this Fetch's offset is at or past the leader's current LEO, now is that time (first branch). Even if not, if this Fetch's offset is at or past the leader's LEO at the previous Fetch, the leader accepts that "at the time of the previous Fetch, it had caught up to the end as it was then" and uses the previous Fetch time (second branch). The comment above the method explains why the second branch is needed: when small produce requests arrive non-stop, the follower's Fetch offset is always a little behind the leader's LEO, and with the first branch alone it would never count as "caught up" (comment on lines 53–68).

This value is used for the ISR decisions in section 4. After recording it, updateFollowerFetchState() goes on to check with maybeExpandIsr(replica) whether to grow the ISR, checks with maybeIncrementLeaderHW() whether the HW can move if the follower's LEO changed, and if anything went up, wakes waiting requests with tryCompleteDelayedRequests() (Partition.scala lines 798–815). This one method is where all three outcomes of replication — the ISR, the HW and the acks=all response — get moved.

The steps for one acks=all record to pass the HW. 1 The producer sends an acks=all Produce to the leader Partition (broker 1). The leader's appendAsLeader() moves its LEO from 10 to 11, but the HW stays at 10 and the request waits as a DelayedProduce. 2 The follower fetcher (broker 2) sends a Fetch with fetchOffset=10. 3 The leader returns record 10 and HW 10. The follower's appendAsFollower() makes its LEO 11 and its HW min(10, 11) = 10. 4 The follower sends its next Fetch with fetchOffset=11. The leader's updateFollowerFetchState() records the follower's LEO as 11 and maybeIncrementLeaderHW() raises the HW to 11. 5 The Produce response (offset 10) goes to the producer. 6 The follower receives HW 11 in its next Fetch response. 7 and 8 When a consumer fetches with fetchOffset=10, it gets record 10, which is below HW 11. Drawn as if there were only one follower. With two followers, step 4 has to come from both before the HW moves (section 3).

What to notice in the picture is steps 2 and 4: two Fetches are needed. The Fetch in step 2 carries the record to the follower, but the leader only learns that "the follower has written it" when the Fetch in step 4 arrives. That round trip is part of acks=all's response time. The follower's HW (step 6) lags the leader's by a beat. When there is nothing new, a follower's Fetch is held at the leader for replica.fetch.wait.max.ms before it returns (a follower's DelayedFetch works off LOG_END, so an HW increase alone doesn't complete it, DelayedFetch.java lines 129–133), so a follower's HW can trail by up to about half a second.

Easy to get wrong: the follower LEO the leader keeps is an "inference"

The follower LEO the leader records is not where the follower has actually written on disk, but the offset of the last Fetch the follower sent. If a follower stops right after writing some records, the leader doesn't know the follower has them. Conversely, within one leader epoch, the follower's real LEO is never smaller than what the leader thinks, because the Fetch offset is an end that has already been written (the case where a follower truncates its log after a leader change comes in section 6). That is why the HW calculation can be wrong on the "too low" side but never on the "too high" side.

3. The high watermark — the smallest LEO in the ISR

The only place that raises the leader's HW is Partition.maybeIncrementLeaderHW(). The comment above the method lists the two occasions it is called: when the ISR changes and when some replica's LEO changes (lines 987–1012).

  private def maybeIncrementLeaderHW(leaderLog: UnifiedLog, currentTimeMs: Long = time.milliseconds): Boolean = {
    if (isUnderMinIsr) {
      trace(s"Not increasing HWM because partition is under min ISR(ISR=${partitionState.isr})")
      return false
    }
    // maybeIncrementLeaderHW is in the hot path, the following code is written to
    // avoid unnecessary collection generation
    val leaderLogEndOffset = leaderLog.logEndOffsetMetadata
    var newHighWatermark = leaderLogEndOffset
    remoteReplicasMap.forEach { (_, replica) =>
      val replicaState = replica.stateSnapshot

      def shouldWaitForReplicaToJoinIsr: Boolean = {
        replicaState.isCaughtUp(leaderLogEndOffset.messageOffset, currentTimeMs, replicaLagTimeMaxMs) &&
        isReplicaIsrEligible(replica.brokerId)
      }

      // Note here we are using the "maximal", see explanation above
      if (replicaState.logEndOffsetMetadata.messageOffset < newHighWatermark.messageOffset &&
          (partitionState.maximalIsr.contains(replica.brokerId) || shouldWaitForReplicaToJoinIsr)
      ) {
        newHighWatermark = replicaState.logEndOffsetMetadata
      }
    }

    leaderLog.maybeIncrementHighWatermark(newHighWatermark).toScala match {

core/src/main/scala/kafka/cluster/Partition.scala lines 1013–1038

Reading the Scala — currentTimeMs: Long = time.milliseconds is an argument with a default value. return false ends the method on the spot, as in Java (it's rare in Scala). forEach { (_, replica) => ... } is Java's Map.forEach((k, v) -> ...), and _ is an "unused argument". You can define a small method with def inside another method.

It reads simply. Start from the leader's own LEO, walk the followers, and if a follower has a smaller LEO, pull the value down to it. Two kinds of followers get to pull it down: those in the ISR (maximalIsr, section 4 below), and those not yet in the ISR but caught up and about to join (shouldWaitForReplicaToJoinIsr). The comment (lines 994–999) explains the second kind: if the leader is alone in the ISR and doesn't wait for a follower that is catching up, the HW (= the leader's LEO) keeps running ahead and the follower never makes it into the ISR.

The isUnderMinIsr check at the very top is worth noticing too. If the ISR is smaller than min.insync.replicas (or the replica count, if that is smaller; effectiveMinIsr(), lines 246–248), the HW doesn't move at all. Even records accepted with acks=1 become invisible to consumers. This ties into the min.insync.replicas discussion in section 5.

The UnifiedLog.maybeIncrementHighWatermark() on the last line only changes the value if the new one is larger (UnifiedLog.java lines 563–581); the one exception is the same offset on a newly rolled segment (because that offset's file position changed). So the HW the leader updates on this path never goes down. (Followers are different: when the truncation of section 6 drops the LEO below the HW, the HW is lowered to the LEO, UnifiedLog.java lines 2391–2392.) When a follower drops out of the ISR and the "smallest LEO" gets bigger, the HW jumps up all at once.

LEOs and the HW on an offset axis. Leader broker 1 holds 0…9 plus 10, 11 and 12, so its LEO is 13; follower broker 2 holds 0…9 plus 10 and 11, LEO 12; follower broker 3 holds only 0…9, LEO 10. The HW is the smallest LEO in the ISR, min(13, 12, 10) = 10, and consumers read only 0…9. Green is records held by all three replicas (before the HW); yellow is records held by only some. If broker 3 drops out of the ISR, the HW jumps to 12.

The HW is written to disk too: replication-offset-checkpoint

The HW lives in memory, but it is written to a file periodically so a restarted broker can use it. ReplicaManager keeps a replication-offset-checkpoint file in each log directory (ReplicaManager.scala line 89 and lines 217–218), and every replica.high.watermark.checkpoint.interval.ms (default 5000) checkpointHighWatermarks() writes the HW of every partition (lines 263–266 and 2125–2147). The file has the same format as the other checkpoint files in part 7 (a version number, a line count, then topic partition value lines). At the end of example 1 we look at this file on all three brokers.

4. Leaving and rejoining the ISR — the leader decides, the controller confirms

The rule for leaving: not caught up even once in replica.lag.time.max.ms

The check that shrinks the ISR runs on a timer, not when a Fetch arrives.

  def startup(): Unit = {
    // start ISR expiration thread
    // A follower can lag behind leader for up to config.replicaLagTimeMaxMs x 1.5 before it is removed from ISR
    scheduler.schedule("isr-expiration", () => maybeShrinkIsr(), 0L, config.replicaLagTimeMaxMs / 2)

core/src/main/scala/kafka/server/ReplicaManager.scala lines 282–285

Reading the Scala — () => maybeShrinkIsr() is a lambda with no arguments (Java's () -> maybeShrinkIsr()): the work the scheduler should run each period, passed as a value. config.replicaLagTimeMaxMs / 2 is integer division.

Every half of replica.lag.time.max.ms (default 30000, ReplicationConfigs.java lines 54–55), Partition.maybeShrinkIsr() is called for every partition on this broker. As the comment says, the threshold is 30 seconds and the check runs every 15, so a lagging follower is dropped somewhere between 30 and 45 seconds. Who is lagging is decided like this.

  def getOutOfSyncReplicas(maxLagMs: Long): Set[Int] = {
    val current = partitionState
    if (!current.isInflight) {
      val candidateReplicaIds = (current.isr.asScala.map(_.toInt) - localBrokerId).toSet
      val currentTimeMs = time.milliseconds()
      val leaderEndOffset = localLogOrException.logEndOffset
      candidateReplicaIds.filter(replicaId => isFollowerOutOfSync(replicaId, leaderEndOffset, currentTimeMs, maxLagMs))
    } else {
      Set.empty
    }
  }

core/src/main/scala/kafka/cluster/Partition.scala lines 1158–1168

Reading the Scala — if (...) { a } else { b } is an expression that produces a value, so at the end of a method it is the return value. set - x is a new set without x, asScala views a Java collection as a Scala one, and map(_.toInt) is short for map(x -> x.toInt()).

Of the ISR minus the leader itself, it picks the followers for which isFollowerOutOfSync() holds, and that check is !ReplicaState.isCaughtUp(...) (lines 1136–1143).

    public boolean isCaughtUp(
        long leaderEndOffset,
        long currentTimeMs,
        long replicaMaxLagMs) {
        return leaderEndOffset == logEndOffset() || currentTimeMs - lastCaughtUpTimeMs <= replicaMaxLagMs;
    }

server/src/main/java/org/apache/kafka/server/replica/ReplicaState.java lines 62–67

"Caught up" means one of two things: its LEO equals the leader's, or replica.lag.time.max.ms has not yet passed since it last fully caught up (lastCaughtUpTimeMs). How many records it is behind (a count) is never looked at — only time. Very old versions of Kafka are said to have had a count-based setting too (replica.lag.max.messages; I haven't checked this against source). That name doesn't appear anywhere in this commit's repository.

The first condition has an interesting consequence. On a partition that receives no new records, a follower doesn't drop out of the ISR even if it is frozen. The leader's LEO and the frozen follower's LEO (as far as the leader knows) are equal, so it is "caught up". Step 4 of example 1 confirms this.

The rule for joining: caught up to the HW and to the start of the current leader epoch

The other direction is checked by maybeExpandIsr() every time a Fetch arrives. There are two conditions.

  private def isFollowerInSync(followerReplica: Replica): Boolean = {
    leaderLogIfLocal.exists { leaderLog =>
      val followerEndOffset = followerReplica.stateSnapshot.logEndOffset
      followerEndOffset >= leaderLog.highWatermark && leaderEpochStartOffsetOpt.exists(followerEndOffset >= _)
    }
  }

core/src/main/scala/kafka/cluster/Partition.scala lines 909–914

Reading the Scala — An Option is a box that may hold a value (Some) or not (None). opt.exists(f) means "there is a value and f is true for it". The _ in followerEndOffset >= _ is the value in the box.

The follower's LEO must be at or past the leader's HW and at or past the offset where the current leader epoch began. The reason for the second condition is in the comment above the method (lines 864–877): there may be committed records between a new leader's HW and LEO, and a follower that joins the ISR before receiving them could become leader and lose them. On top of that come the checks in isReplicaIsrEligible() — the broker must not be fenced (marked "not alive" by the controller), must not be in controlled shutdown, and the broker epoch in its Fetch must match the one in the metadata (lines 916–934). That last one keeps a stale Fetch from a restarted broker, arriving late, from wrongly putting it into the ISR.

The leader only proposes: PendingShrinkIsr and AlterPartition

Even when the leader decides "let's shrink the ISR to 1,2", it doesn't change the ISR on the spot. The ISR is a value that must be recorded in the controller's metadata log (part 10), and if the leader changes, the new leader must know the same ISR. So the partitionState of a Partition takes one of three shapes.

The ISR states of a leader Partition. makeLeader() starts it in CommittedPartitionState (the ISR confirmed by the controller). When maybeShrinkIsr() finds a lagging follower it moves to PendingShrinkIsr (the HW still uses the old ISR); when maybeExpandIsr() finds a follower that caught up it moves to PendingExpandIsr (the HW already counts the new replica). Both pending states return to Committed, through handleAlterPartitionUpdate() when AlterPartition succeeds, or back to lastCommittedState on failures such as INELIGIBLE_REPLICA. All three states are Java records in server/.../partition/ (CommittedPartitionState, PendingShrinkIsr, PendingExpandIsr).

The pending states mean two things. First, while one change is in progress (isInflight), no other change starts (the if (!current.isInflight) in getOutOfSyncReplicas()). Second, the maximalIsr used for the HW calculation differs by state. The comments in prepareIsrExpand() and prepareIsrShrink() give the reason.

    // When expanding the ISR, we assume that the new replica will make it into the ISR
    // before we receive confirmation that it has. This ensures that the HW will already
    // reflect the updated ISR even if there is a delay before we receive the confirmation.
    // Alternatively, if the update fails, no harm is done since the expanded ISR puts
    // a stricter requirement for advancement of the HW.

core/src/main/scala/kafka/cluster/Partition.scala lines 1616–1620

    // When shrinking the ISR, we cannot assume that the update will succeed as this could
    // erroneously advance the HW if the `AlterPartition` were to fail. Hence the "maximal ISR"
    // for `PendingShrinkIsr` is the current ISR.

core/src/main/scala/kafka/cluster/Partition.scala lines 1643–1645

The principle is one: the HW must never run ahead because of a change that isn't confirmed. While growing the ISR, the new replica is counted in advance, making the HW more conservative; while shrinking, the departing replica is still counted, so the HW doesn't move until the controller accepts. That is why an acks=all request held up by a departing follower is only released when the AlterPartition response comes back (example 1 times this).

The proposal goes to alterIsrManager.submit() (submitAlterPartition(), lines 1690–1724). On the receiving side, DefaultAlterPartitionManager looks like this.

    @Override
    public CompletableFuture<LeaderAndIsr> submit(TopicIdPartition topicIdPartition,
                                                  LeaderAndIsr leaderAndIsr) {
        CompletableFuture<LeaderAndIsr> future = new CompletableFuture<>();
        AlterPartitionItem alterPartitionItem = new AlterPartitionItem(topicIdPartition, leaderAndIsr, future);
        boolean enqueued = unsentIsrUpdates.putIfAbsent(alterPartitionItem.topicIdPartition(), alterPartitionItem) == null;
        if (enqueued) {
            maybePropagateIsrChanges();
        } else {
            future.completeExceptionally(new OperationNotAttemptedException(String.format(
                    "Failed to enqueue ISR change state %s for partition %s", leaderAndIsr, topicIdPartition)));
        }
        return future;
    }

    void maybePropagateIsrChanges() {
        // Send all pending items if there is not already a request in-flight.
        if (!unsentIsrUpdates.isEmpty() && inflightRequest.compareAndSet(false, true)) {
            // Copy current unsent ISRs but don't remove from the map, they get cleared in the response handler
            List<AlterPartitionItem> inflightAlterPartitionItems = new ArrayList<>(unsentIsrUpdates.values());
            sendRequest(inflightAlterPartitionItems);
        }
    }

server/src/main/java/org/apache/kafka/server/partition/DefaultAlterPartitionManager.java lines 103–125

Each partition can have only one unsent change (putIfAbsent), and only one AlterPartition request at a time for the whole broker flies to the controller (inflightRequest). Changes for other partitions that pile up meanwhile go out together in the next request. This keeps a broker with thousands of partitions from flooding the controller with requests. The request is sent by the broker-1-to-controller-alter-partition-channel-manager thread we saw in example 1-1 (DefaultAlterPartitionManager.java lines 80–88; the thread name format is in NodeToControllerChannelManagerImpl.java line 79).

On the controller, ReplicationControlManager.alterPartition() checks the broker epoch and partition epoch and checks again, for every broker in the new ISR, that it is registered, not in controlled shutdown, not fenced and has a matching broker epoch (ineligibleReplicasForIsr(), ReplicationControlManager.java lines 1418–1438). If that passes, it writes a PartitionChangeRecord to the metadata log, bumps the partition epoch and responds (from line 1093). The leader takes the response in handleAlterPartitionUpdate(), returns to CommittedPartitionState and calls maybeIncrementLeaderHW() once more with the new ISR (Partition.scala lines 1797–1828). The ISR updated to ... and version updated to N line in the server log is printed here.

Easy to get wrong: there are two ways the ISR shrinks

Besides the leader shrinking it with maybeShrinkIsr(), there is a way the controller shrinks it directly. When a broker starts a controlled shutdown (handleBrokerInControlledShutdown()), or is fenced after its heartbeats stop for broker.session.timeout.ms (default 9000, KRaftConfigs.java lines 43–44) (handleBrokerFenced()), the controller produces records that remove that broker from every ISR it is in (ReplicationControlManager.java lines 1449–1460 and 1514–1523). Stopping a broker with kafka-server-stop.sh (SIGTERM) takes this path, so the ISR shrinks almost immediately; only a broker that is alive but slow takes the leader's path. Example 1 raises broker.session.timeout.ms to 30 seconds to see only the leader's path. With the default, the frozen broker would be fenced after 9 seconds and fall into the controller's path.

Example 1-2: a frozen follower, the ISR, the HW and acks=all

The same run.sh starts the cluster with replica.lag.time.max.ms=5000 and broker.session.timeout.ms=30000. The check runs every half of that, 2.5 seconds. The steps are:

  1. Write 10 records with acks=all.
  2. Freeze broker 3 with kill -STOP (the process is frozen, but its sockets stay open), wait 12 seconds without writing anything, and look at the ISR. Then wake it with kill -CONT.
  3. A Java program (IsrWait.java) creates two producers and sends one record with each, then freezes broker 3 again, immediately sends 5 records with acks=1, asks for the HW, then sends 1 record with acks=all and times how long the response takes.
  4. Wake broker 3 and see whether the ISR comes back.

Step 3 is done inside Java because starting a JVM takes a few seconds. At first I froze the broker from the shell and then started Java, but when the container was busy the program took 17 seconds to send its first record, and the threshold (5 seconds) had passed by then. The HW is asked with the Admin client's listOffsets(OffsetSpec.latest()). ListOffsets(LATEST) without an isolation level returns the end a consumer can read, i.e. the HW.

        try (KafkaProducer<String, String> acks1 = producer("1");
             KafkaProducer<String, String> acksAll = producer("all")) {
            // Warm-up: each producer sends one record (the follower is alive, so both finish quickly)
            acks1.send(new ProducerRecord<>(topic, "warmup-acks1")).get();
            acksAll.send(new ProducerRecord<>(topic, "warmup-acksall")).get();
            System.out.printf("before SIGSTOP: high watermark = %d%n", highWatermark(tp));

            new ProcessBuilder("kill", "-STOP", followerPid).start().waitFor();
            stoppedAt = System.currentTimeMillis();
            System.out.println("SIGSTOP sent to the follower");

            for (int i = 0; i < 5; i++) {
                RecordMetadata md = acks1.send(new ProducerRecord<>(topic, "acks1-" + i)).get();
                System.out.printf("acks=1   offset=%d  (t+%d ms)%n", md.offset(), since());
            }
            System.out.printf("high watermark = %d  (t+%d ms)%n", highWatermark(tp), since());

            long start = System.currentTimeMillis();
            RecordMetadata md = acksAll.send(new ProducerRecord<>(topic, "acksall-0")).get();
            System.out.printf("acks=all offset=%d  waited %d ms  (t+%d ms)%n",
                    md.offset(), System.currentTimeMillis() - start, since());
            System.out.printf("high watermark = %d  (t+%d ms)%n", highWatermark(tp), since());

[shortened from ex/u09-isr-hw/IsrWait.java]

The output follows. Steps 1 and 2 (creating the topic, and example 1-1's threads) and the final stopped line are left out. The rep-0 Leader: … lines are printed by the example's Isr.java, which asks through the Admin client. The times (t+… ms, measured from the freeze), waited … ms, the lastCaughtUpTimeMs value, and the order in which the server log prints the ISR (2,1,3 or 3,2,1) differ on every run. The timestamps at the start of the server log lines were removed.

=== 3. write 10 records with acks=all
rep:0:10
=== 4. SIGSTOP broker 3, no new records, wait 12s
rep-0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
=== 5. Java: warm up, SIGSTOP broker 3 again, then produce
before SIGSTOP: high watermark = 12
SIGSTOP sent to the follower
acks=1   offset=12  (t+23 ms)
acks=1   offset=13  (t+36 ms)
acks=1   offset=14  (t+51 ms)
acks=1   offset=15  (t+66 ms)
acks=1   offset=16  (t+75 ms)
high watermark = 12  (t+160 ms)
acks=all offset=17  waited 4964 ms  (t+5126 ms)
high watermark = 18  (t+5191 ms)
rep-0 Leader: 1 Replicas: 1,2,3 Isr: 1,2
--- leader (broker 1) server.log
INFO [Partition rep-0 broker=1] Shrinking ISR from 2,1,3 to 1,2. Leader: (highWatermark: 12, endOffset: 18). Out of sync replicas: (brokerId: 3, endOffset: 12, lastCaughtUpTimeMs: 1794027649149). (kafka.cluster.Partition)
INFO [Partition rep-0 broker=1] ISR updated to 1,2  and version updated to 1 (kafka.cluster.Partition)
=== 6. SIGCONT broker 3, wait 5s
rep-0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3
--- leader (broker 1) server.log
INFO [Partition rep-0 broker=1] ISR updated to 2,1,3  and version updated to 2 (kafka.cluster.Partition)
=== 7. replication-offset-checkpoint (high watermark on disk)
broker 1: rep 0 18
broker 2: rep 0 18
broker 3: rep 0 18

Let's match it with the source line by line.

5. What does acks=all wait for — DelayedProduce and min.insync.replicas

In part 6, a Produce request was written to the leader's log in ReplicaManager.appendRecords() and then handed to maybeAddDelayedProduce(). Whether to respond right away or wait is decided by this condition.

  // If all the following conditions are true, we need to put a delayed produce request and wait for replication to complete
  //
  // 1. required acks = -1
  // 2. there is data to append
  // 3. at least one partition append was successful (fewer errors than partitions)
  private def delayedProduceRequestRequired(requiredAcks: Short,
                                            entriesPerPartition: Map[TopicIdPartition, MemoryRecords],
                                            localProduceResults: Map[TopicIdPartition, LogAppendResult]): Boolean = {
    requiredAcks == -1 &&
    entriesPerPartition.nonEmpty &&
    localProduceResults.values.count(_.exception().isPresent) < entriesPerPartition.size
  }

core/src/main/scala/kafka/server/ReplicaManager.scala lines 1356–1367

Reading the Scala — When a method's body is a single expression, its value is the return value. count(_.exception().isPresent) is "the number of results that have an exception".

acks=all travels on the wire as -1 (part 1). acks=0 and 1 are filtered out by this condition and answered as soon as the leader has written (with acks=0 no response is sent at all; the connection is closed only if there was an error, KafkaApis.scala lines 512–530). With -1, a DelayedProduce is created. The target it waits for is, per partition, requiredOffset = the last offset written + 1 (ReplicaManager.scala lines 838–840).

      val delayedProduce = new DelayedProduce(timeoutMs, initialProduceStatus.asJava, delegate, responseCallback.asJava)

      // create a list of (topic, partition) pairs to use as keys for this delayed produce operation
      val producerRequestKeys = entriesPerPartition.keys.map(new TopicPartitionOperationKey(_)).toList

      // try to complete the request immediately, otherwise put it into the purgatory
      // this is because while the delayed produce operation is being created, new
      // requests may arrive and hence make this operation completable.
      delayedProducePurgatory.tryCompleteElseWatch(delayedProduce, producerRequestKeys.asJava)

core/src/main/scala/kafka/server/ReplicaManager.scala lines 903–911

Reading the Scala — the _ in keys.map(new TopicPartitionOperationKey(_)) stands for each element, so it is the same as keys.map(k -> new TopicPartitionOperationKey(k)). .toList makes a Scala list, and .asJava turns a Scala collection into a Java one so it can be passed to a Java method.

tryCompleteElseWatch() is the same purgatory mechanism as part 8's DelayedFetch: try once, and if that doesn't work, hang it up with the partitions as keys. The time limit timeoutMs is the TimeoutMs field of the Produce request (KafkaApis.scala lines 548–549), and the producer's Sender puts request.timeout.ms (default 30000) into that field (Sender.java line 890 and lines 930–934). In other words, your app's request.timeout.ms is how long the broker will wait on acks=all for you.

When created, a DelayedProduce writes REQUEST_TIMED_OUT into the error code in advance.

        // first update the acks pending variable according to the error code
        produceStatus.forEach((topicPartition, status) -> {
            if (status.responseStatus.error == Errors.NONE) {
                // Timeout error state will be cleared when required acks are received
                status.acksPending = true;
                status.responseStatus.error = Errors.REQUEST_TIMED_OUT;
            } else {
                status.acksPending = false;
            }

server/src/main/java/org/apache/kafka/server/purgatory/DelayedProduce.java lines 110–118

When the condition is met, tryComplete() clears that error (lines 152–155); when time runs out, the pre-written REQUEST_TIMED_OUT goes into the response as it is. The condition is judged by Partition.checkEnoughReplicasReachOffset(), called through a delegate function that ReplicaManager passes in.

        val minIsr = effectiveMinIsr(leaderLog)
        if (leaderLog.highWatermark >= requiredOffset) {
          /*
           * The topic may be configured not to accept messages if there are not enough replicas in ISR
           * in this scenario the request was already appended locally and then added to the purgatory before the ISR was shrunk
           */
          if (minIsr <= curMaximalIsr.size)
            (true, Errors.NONE)
          else
            (true, Errors.NOT_ENOUGH_REPLICAS_AFTER_APPEND)
        } else
          (false, Errors.NONE)

core/src/main/scala/kafka/cluster/Partition.scala lines 970–981

Reading the Scala — (true, Errors.NONE) is a two-value tuple. The method returns "is it done, and with which error" as a pair.

What acks=all waits for is not "every replica in the ISR" but "the HW passing my record". They sound the same but aren't. As section 3 showed, the HW is computed from the ISR at that moment, so when a follower drops out of the ISR, the HW moves without waiting for it. The acks=all in example 1 finished not because broker 3 caught up but because broker 3 left the ISR. The guarantee of acks=all is "the ISR at response time has it all", and what sets the lower bound on the size of that ISR is min.insync.replicas.

That lower bound bites in two places. One is the NOT_ENOUGH_REPLICAS_AFTER_APPEND in the code above, for when the ISR shrank below the bound while waiting. In that case the record has already been written to the leader's log (the comment: "the request was already appended locally"). The other is before writing.

          val minIsr = effectiveMinIsr(leaderLog)
          val inSyncSize = partitionState.isr.size

          // Avoid writing to leader if there are not enough insync replicas to make it safe
          if (inSyncSize < minIsr && requiredAcks == -1) {
            throw new NotEnoughReplicasException(s"The size of the current ISR : $inSyncSize " +
              s"is insufficient to satisfy the min.isr requirement of $minIsr for partition $topicPartition, " +
              s"live replica(s) broker.id are : $inSyncReplicaIds")
          }

core/src/main/scala/kafka/cluster/Partition.scala lines 1233–1241

This is the start of Partition.appendRecordsToLeader(). If it's acks=all and the ISR is below the bound, the request is rejected without writing to the log. acks=1 is not subject to this check (the requiredAcks == -1 condition). min.insync.replicas defaults to 1 (ServerLogConfigs.java lines 158–159), so with default settings acks=all succeeds even when the leader is alone in the ISR. The producer's acks defaults to "all" (ProducerConfig.java lines 430–435), so "the defaults are safe" is only true once you raise min.insync.replicas on the broker side to 2 or more.

Where Produce handling branches by acks. When a Produce request (acks, TimeoutMs = request.timeout.ms) arrives, it first checks whether it is acks=all with an ISR smaller than min.insync.replicas. If so, it is rejected with NOT_ENOUGH_REPLICAS without writing. Otherwise appendAsLeader() writes it to the log, and if acks is not -1 the response goes out right away (acks=0 and 1). With -1 it waits in the purgatory as a DelayedProduce (requiredOffset = last offset + 1), and on every follower Fetch or ISR change checkEnoughReplicasReachOffset() checks again whether HW ≥ requiredOffset. When it is, the response is NONE (or NOT_ENOUGH_REPLICAS_AFTER_APPEND if the ISR shrank meanwhile); when TimeoutMs passes, the response is REQUEST_TIMED_OUT. The red boxes are failures the producer receives; the green boxes are responses. Only the first rejection leaves the record unwritten.

Who makes the purgatory look at a DelayedProduce again is in the source too. The purgatory doesn't watch conditions by itself; somebody has to call checkAndComplete(partition). Those calls are the tryCompleteDelayedRequests() at the end of section 2's updateFollowerFetchState() (→ DelayedOperations.checkAndCompleteAll(), Partition.scala lines 69–76), the one after section 4's handleAlterPartitionUpdate() (lines 1714–1716), and the one when a write to the leader raised the HW (addCompletePurgatoryAction(), ReplicaManager.scala lines 853–876). In other words, what wakes up acks=all is usually the followers' Fetches (when it is released because the ISR shrank, as in example 1, the AlterPartition response wakes it). Timeouts are reaped by the ExpirationReaper-1-Produce thread we saw in part 2.

Example 2: turning off one broker with min.insync.replicas=3

ex/u09-min-isr/run.sh creates a topic strict with replicas 1:2:3 and min.insync.replicas=3, and runs a Java program (MinIsr.java) that sends one record with acks=all and one with acks=1 three times: at the start, after stopping broker 3 with kafka_stop_node 3 (SIGTERM, controlled shutdown), and after lowering the topic setting to min.insync.replicas=2. The producer is set to retries=0. NotEnoughReplicasException is a retriable error, so otherwise the producer would keep resending for delivery.timeout.ms (default 2 minutes) and end with a timeout.

            props.put(ProducerConfig.ACKS_CONFIG, acks);
            // Don't retry — NotEnoughReplicas is a retriable error, so otherwise the producer resends for delivery.timeout.ms
            props.put(ProducerConfig.RETRIES_CONFIG, "0");
            props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "false");

[shortened from ex/u09-min-isr/MinIsr.java]

=== 1. topic strict, replicas 1,2,3, min.insync.replicas=3
 Topic: strict Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Elr: LastKnownElr: 
acks=all -> ok, offset=0
acks=1   -> ok, offset=1
=== 2. stop broker 3 (SIGTERM, controlled shutdown)
 Topic: strict Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2 Elr: 3 LastKnownElr: 
acks=all -> NotEnoughReplicasException: Messages are rejected since there are fewer in-sync replicas than required.
acks=1   -> ok, offset=2
=== 3. min.insync.replicas=2
acks=all -> ok, offset=3
acks=1   -> ok, offset=4
=== 4. offsets on the leader's disk (broker 1)
offset: 0 payload: acks-all
offset: 1 payload: acks-1
offset: 2 payload: acks-1
offset: 3 payload: acks-all
offset: 4 payload: acks-1

6. When the leader changes — truncating a diverged log with leader epochs

The new leader records where its epoch begins and re-measures every follower from scratch

When the controller picks a new leader (leader epoch + 1), Partition.makeLeader() runs on the broker that receives that metadata. Here is the core of what it does when the epoch changed.

      if (isNewLeaderEpoch) {
        val leaderEpochStartOffset = leaderLog.logEndOffset
        // ...
        leaderLog.assignEpochStartOffset(partitionRegistration.leaderEpoch, leaderEpochStartOffset)

        // Initialize lastCaughtUpTime of replicas as well as their lastFetchTimeMs and
        // lastFetchLeaderLogEndOffset.
        remoteReplicas.foreach { replica =>
          replica.resetReplicaState(
            currentTimeMs,
            leaderEpochStartOffset,
            isNewLeader,
            isr.contains(replica.brokerId)
          )
        }

core/src/main/scala/kafka/cluster/Partition.scala lines 637–662

Reading the Scala — remoteReplicas.foreach { replica => ... } is Java's forEach(replica -> ...). A lambda in braces can be passed without parentheses.

The new leader writes "epoch N starts at my current LEO" into its epoch cache (assignEpochStartOffset()). That cache is LeaderEpochFileCache, stored as epoch startOffset lines in the leader-epoch-checkpoint file of the partition directory (LeaderEpochCheckpointFile.java line 45). Then it resets each follower's state with resetReplicaState(). When it has just become leader, it sets each follower's LEO to "unknown" and sets lastCaughtUpTimeMs to now only for followers that were in the ISR, so they aren't kicked out of the ISR right away (Replica.java lines 108–146).

On a broker that became a follower, makeFollower() clears the ISR information and records the new leader and epoch (lines 696–745), and ReplicaManager detaches the fetcher and reattaches it toward the new leader. Where to fetch from is decided here.

  protected def initialFetchOffset(log: UnifiedLog): Long = {
    if (log.latestEpoch.isPresent)
      log.logEndOffset
    else
      log.highWatermark
  }

core/src/main/scala/kafka/server/ReplicaManager.scala lines 2102–2107

If it has epoch information, it asks to fetch from its own LEO. The problem is that the tail of its log may differ from the new leader's. If the old leader had records accepted with acks=1 that only it holds, the new leader will be writing different records at those offsets.

The Fetch response tells it where the logs diverged

Section 1 said that a follower puts lastFetchedEpoch in its Fetch. Before reading, the leader uses that value to check whether the follower's log is on the same path as its own.

    lastFetchedEpoch.ifPresent { fetchEpoch =>
      val epochEndOffset = lastOffsetForLeaderEpoch(currentLeaderEpoch, fetchEpoch, fetchOnlyFromLeader = false)
      // ...
      if (epochEndOffset.leaderEpoch < fetchEpoch || epochEndOffset.endOffset < fetchOffset) {
        val divergingEpoch = new FetchResponseData.EpochEndOffset()
          .setEpoch(epochEndOffset.leaderEpoch)
          .setEndOffset(epochEndOffset.endOffset)

        return new LogReadInfo(
          FetchDataInfo.empty(fetchOffset),
          Optional.of(divergingEpoch),
          initialHighWatermark,
          initialLogStartOffset,
          initialLogEndOffset,
          initialLastStableOffset)
      }
    }

core/src/main/scala/kafka/cluster/Partition.scala lines 1386–1417

lastOffsetForLeaderEpoch() asks the leader's epoch cache "where did epoch E end?". The end is "the start offset of the epoch after E" (LeaderEpochFileCache.endOffsetFor(), lines 284–328). If the follower says "I have epoch-0 records up to just before offset 15" but the leader knows epoch 0 ended at 10, the follower's 10–14 are records the leader doesn't have. The leader then returns divergingEpoch(epoch 0, end 10) instead of records. In this case the follower's state isn't updated either (the !logReadInfo.divergingEpoch.isPresent condition in section 2's fetchRecords()).

When the follower's processFetchRequest() sees this response, it doesn't process records (lines 282–290); it decides the offset to truncate to via truncateOnFetchResponse() → truncationOffset().

    val replicaEndOffset = logEndOffset(tp)

    // get (leader epoch, end offset) pair that corresponds to the largest leader epoch
    // less than or equal to the requested epoch.
    val endOffsetForEpochOpt = endOffsetForEpoch(tp, leaderEpochOffset.leaderEpoch)
    if (endOffsetForEpochOpt.isPresent) {
      val offsetAndEpoch = endOffsetForEpochOpt.get
      val followerEpochEndOffset = offsetAndEpoch.offset
      val followerEpoch = offsetAndEpoch.epoch()
      if (followerEpoch != leaderEpochOffset.leaderEpoch) {
        // ...
      } else {
        val offsetToTruncateTo = min(followerEpochEndOffset, leaderEpochOffset.endOffset)
        min(offsetToTruncateTo, replicaEndOffset)
      }

core/src/main/scala/kafka/server/AbstractFetcherThread.scala lines 507–529

Reading the Scala — min(a, b) is the function from scala.math._, imported at the top of the file. if (...) { ... } else { ... } is an expression that yields a value, so the last line, min(offsetToTruncateTo, replicaEndOffset), is directly the offset to truncate to.

It truncates to the smallest of the end the leader gave, the end of that epoch as the follower itself knows it, and the follower's LEO (truncate() → Partition.truncateTo()), and fetches again from that offset. If the follower doesn't know the epoch the leader named (the branch elided here), it truncates to the end of the earlier epoch it does know, and if the leader reports a divergence again on the next Fetch, it truncates once more (the comment on lines 489–504). A log with no epoch information at all (empty, or in a very old format) can't use this method, so partitionFetchState() puts that partition in the TRUNCATING state and section 1's maybeTruncate() truncates it to the HW (lines 144–175 and 427–436).

The steps to truncate a diverged log. The log of old leader broker 1 (now a follower) is 0…14, LEO 15, and 10…14 are epoch 0 and only broker 1 has them. New leader broker 2's epoch cache is (0, 0) (1, 10): epoch 1 starts at 10. 1 Broker 1 fetches with fetchOffset=15, lastFetchedEpoch=0. Broker 2's Partition.readRecords() gets lastOffsetForLeaderEpoch(0) = 10, and since 10 is below 15 decides the logs diverged. 2 It returns no records but divergingEpoch(epoch 0, endOffset 10). Broker 1's truncationOffset() computes min(10, 15, 15) = 10 and truncate(10) drops 10…14. 3 It fetches again with fetchOffset=10, lastFetchedEpoch=0. 4 It receives records 10…12 (epoch 1). Drawn with the real numbers from example 3. It starts with broker 1 holding lost-1 and broker 2 holding new-1 at the same offset, 10.

What changed since 4.4.0: the path that asked with OffsetsForLeaderEpoch is gone

In 4.4.0, AbstractFetcherThread had a path that asked the leader for the truncation point with a separate OffsetsForLeaderEpoch request (truncateToEpochEndOffsets()). But the RemoteLeaderEndPoint that followers use already had isTruncationOnFetchSupported = true in 4.4.0, so partitions with epoch information were truncated by the Fetch response's divergingEpoch, as in this post, even then. The separate path remained for the endpoint that didn't support it — LocalLeaderEndPoint (false), which moves logs between directories inside one broker (checked in the 4.4.0 source). Commit b234a3a667 from 2026-10-08, 16 days before our reference commit, "KAFKA-18477 Remove usage of OffsetForLeaderEpochRequest in AbstractFetcherThread", removed that path. So in this commit maybeTruncate() is left with only the truncate-to-HW case, and LeaderEndPoint.fetchEpochEndOffsets() is used only by tiered storage's TierStateMachine. The side where a broker receives and handles OffsetsForLeaderEpoch requests (KafkaApis.handleOffsetForLeaderEpochRequest(), line 2196) is still there; consumers also use that request to check that their offset wasn't truncated after a rebalance (the comment in endOffsetFor(), lines 294–296).

Example 3: acks=1 records that only the leader had disappear

ex/u09-truncation/run.sh writes ok-1–ok-10 with acks=all to a topic div with replicas 1:2:3, then runs the time-sensitive part in about 2 seconds in a Java program, Diverge.java: SIGSTOP the two followers (brokers 2 and 3) → wait 1 second → lost-1–lost-5 with acks=1 → kill -9 the leader (broker 1) → SIGCONT the two followers. Then it waits for a new leader to be elected, writes new-1–new-3 with acks=all, and restarts broker 1. replica.lag.time.max.ms is set to 10 seconds so the leader won't drop the followers from the ISR before it dies.

At first I did this sequence from the shell with the console producer, but while the console producer took more than 10 seconds to start, the leader dropped the frozen followers from the ISR. Then the leader died with itself as the only ISR member, giving Leader: none … Isr: Elr: 1, and the restarted broker 1 came back as leader, so the lost-* records survived. That was the rule "only a replica in the ISR can become leader" doing its job, but it wasn't what this example meant to show, so I switched to a Java program.

Even after switching to Java, one run had lost-1 replicated to the followers. It looks like this: the Fetch the follower sent just before freezing waits in the leader's purgatory for up to replica.fetch.wait.max.ms (500ms); if lost-1 is written meanwhile, that Fetch completes carrying lost-1, the response lands in the frozen follower's socket buffer, and the follower writes it down once it wakes up (this is a guess explained from the source's DelayedFetch; I didn't confirm it at the packet level). So the program now waits 1 second after freezing before writing, and I ran it twice afterwards with the same result both times, shown below.

The batch lines are kafka-dump-log.sh --print-data-log output shortened with sed, and leader-epoch-checkpoint is the file's contents joined onto one line (version 0, the line count, then epoch startOffset pairs). The ok-2–ok-9 record lines are shortened to …. The time since the freeze (ms after SIGSTOP) differs on every run.

=== 1. topic div, replicas 1,2,3 (leader 1), 10 records with acks=all
 Topic: div Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Elr: LastKnownElr: 
--- leader-epoch-checkpoint (version, count, then 'epoch startOffset')
broker 1: 0 1 0 0 
broker 2: 0 1 0 0 
broker 3: 0 1 0 0 
=== 2. Java: SIGSTOP followers 2,3 -> 5 records with acks=1 -> kill -9 leader 1 -> SIGCONT 2,3
acks=1 offset=10 (only the leader has it)
acks=1 offset=11 (only the leader has it)
acks=1 offset=12 (only the leader has it)
acks=1 offset=13 (only the leader has it)
acks=1 offset=14 (only the leader has it)
leader killed, followers resumed (1171 ms after SIGSTOP)
--- broker 1 log (process is dead, the file is still there)
  batch offsets 0-9 (count 10) partitionLeaderEpoch=0
    offset 0: ok-1
    …
    offset 9: ok-10
  batch offsets 10-10 (count 1) partitionLeaderEpoch=0
    offset 10: lost-1
  batch offsets 11-11 (count 1) partitionLeaderEpoch=0
    offset 11: lost-2
  batch offsets 12-12 (count 1) partitionLeaderEpoch=0
    offset 12: lost-3
  batch offsets 13-13 (count 1) partitionLeaderEpoch=0
    offset 13: lost-4
  batch offsets 14-14 (count 1) partitionLeaderEpoch=0
    offset 14: lost-5
=== 3. wait for a new leader, then 3 records with acks=all
 Topic: div Partition: 0 Leader: 2 Replicas: 1,2,3 Isr: 2,3 Elr: LastKnownElr: 
--- broker 2 log (new leader)
  batch offsets 0-9 (count 10) partitionLeaderEpoch=0
    offset 0: ok-1
    …
    offset 9: ok-10
  batch offsets 10-12 (count 3) partitionLeaderEpoch=1
    offset 10: new-1
    offset 11: new-2
    offset 12: new-3
broker 1: 0 1 0 0 
broker 2: 0 2 0 0 1 10 
broker 3: 0 2 0 0 1 10 
=== 4. restart broker 1
 Topic: div Partition: 0 Leader: 2 Replicas: 1,2,3 Isr: 1,2,3 Elr: LastKnownElr: 
--- broker 1 server.log
INFO [ReplicaFetcher replicaId=1, leaderId=2, fetcherId=0] Truncating partition div-0 to offset 10 due to leader epoch and offset EpochEndOffset(errorCode=0, partition=0, leaderEpoch=0, endOffset=10) (kafka.server.ReplicaFetcherThread)
INFO [UnifiedLog partition=div-0, dir=/tmp/kafka/1/data] Truncating to offset 10 (org.apache.kafka.storage.internals.log.UnifiedLog)
--- broker 1 log after truncation
  batch offsets 0-9 (count 10) partitionLeaderEpoch=0
    offset 0: ok-1
    …
    offset 9: ok-10
  batch offsets 10-12 (count 3) partitionLeaderEpoch=1
    offset 10: new-1
    offset 11: new-2
    offset 12: new-3
broker 1: 0 2 0 0 1 10 
broker 2: 0 2 0 0 1 10 
broker 3: 0 2 0 0 1 10 
--- console consumer from the beginning
ok-1 ok-2 ok-3 ok-4 ok-5 ok-6 ok-7 ok-8 ok-9 ok-10 new-1 new-2 new-3 

What this post leaves out

Summary

Concept In one line
ReplicaFetcherThread-<fetcherId>-<leader id> On a follower broker, one per leader broker (default num.replica.fetchers=1). It handles all of that leader's partitions and keeps sending Fetch requests
A follower's Fetch The same request as a consumer's, plus replicaId, the broker epoch and lastFetchedEpoch. The leader reads to the end of the log (LOG_END) for it
appendAsFollower() Appends batches as they are, without changing the offsets and epochs the leader assigned
A follower's LEO The leader learns it from the fetchOffset the follower sends. There is no separate acknowledgement
lastCaughtUpTimeMs The last time the follower caught up to the leader's LEO. The basis of ISR decisions
High watermark The smallest LEO in the ISR (maximalIsr, which counts in-flight changes conservatively). The leader's computation never lowers it, and it doesn't move while the ISR is below min.insync.replicas
A follower's HW min(the HW the leader sent, my LEO). A beat behind the leader
Shrinking the ISR Checked every replica.lag.time.max.ms / 2. Followers whose LEO differs from the leader's and that haven't caught up within the threshold are removed. With no new records, even a frozen follower stays
Growing the ISR Follower LEO ≥ HW and ≥ the start offset of the current leader epoch, and not fenced or shutting down
AlterPartition The leader's proposal. One request at a time per broker. Final only once the controller checks it and records a PartitionChangeRecord. On controlled shutdown or fencing the controller shrinks the ISR itself
acks=all Waits as a DelayedProduce until HW ≥ last offset + 1, for at most the producer's request.timeout.ms. Woken by followers' Fetches and ISR changes
min.insync.replicas With acks=all and too small an ISR, NOT_ENOUGH_REPLICAS before writing; if it becomes too small while waiting, NOT_ENOUGH_REPLICAS_AFTER_APPEND. Default 1
Leader epoch A number that goes up with every leader change. Stamped on every batch; its start offsets are kept in leader-epoch-checkpoint
Truncation The leader uses the follower's lastFetchedEpoch to find where the logs diverged and reports it as divergingEpoch; the follower truncates there. In this commit the path that asked with OffsetsForLeaderEpoch is gone

What's next

Throughout this post we kept saying "the controller decides" and "it spreads through the metadata". That is the side that picks leaders, receives AlterPartition and writes PartitionChangeRecords, and fences brokers. The next post goes to that controller. Interestingly, the controller's metadata log, __cluster_metadata, is also copied by sending Fetch requests to a leader, and commits by treating the point a majority has received as its high watermark — a Raft majority, not this post's ISR. In Part 10: KRaft and Metadata — How the Controller Remembers the Cluster we look at KafkaRaftClient's role transitions, how QuorumController builds records on a single-threaded event queue, and how a broker takes those records all the way to this post's makeLeader() and makeFollower().

If you'd like to compare replication with another system, the MariaDB series covers how a database keeps a record of its changes and recovers from it.

More posts