Topic Management — Leaders, Replicas, and the TopicManager

Topic Management — Leaders, Replicas, and the TopicManager

The TopicManager is the per-node application layer that ties everything together. Cluster-wide topic metadata (leader, epoch, replica set, ISR) lives in broker/cluster.ClusterMetadataStore, covered on the previous page — TopicManager is a lean consumer of that state: it only tracks which logs are open on this node, and asks the cluster for everything else through the TopicCoordinator interface. Code lives in broker/topic/.

Step 1: Understand what TopicManager owns

TopicManager struct

// broker/topic/topic.go
package topic

type TopicManager struct {
    mu sync.RWMutex
    // Topics is local runtime state only: this node's open log handles, keyed by
    // topic name. Cluster-wide state (leader, epoch, replica set, ISR) lives behind
    // TopicCoordinator, not here.
    Topics               map[string]*log.LogManager
    BaseDir              string
    Logger               *zap.Logger
    CurrentNodeID        string // Local node ID from config; not persisted.
    coordinator          TopicCoordinator
    stopPeriodic         chan struct{}
    replicationCancel    context.CancelFunc // non-nil while the replication/reconcile loops are running
    replConns            *replicationConnCache
    replicationBatchSize uint32
    ISRLagThreshold      uint64 // max record lag for ISR membership
}

Topics is just map[string]*log.LogManager: a name and an open log, nothing else. TopicManager doesn't implement any Raft-facing interface, doesn't hold JSON-tagged fields, and doesn't do any snapshotting — all of that belongs to ClusterMetadataStore. This package's only job is to keep the right logs open on the right nodes and to serve produce requests against them.

NewTopicManager

func NewTopicManager(baseDir string, coord TopicCoordinator, logger *zap.Logger) (*TopicManager, error) {
    if logger == nil {
        logger = zap.NewNop()
    }
    tm := &TopicManager{
        Topics:          make(map[string]*log.LogManager),
        BaseDir:         baseDir,
        Logger:          logger,
        coordinator:     coord,
        stopPeriodic:    make(chan struct{}),
        replConns:       newReplicationConnCache(),
        ISRLagThreshold: DefaultISRLagThreshold,
    }
    go tm.periodicLog(defaultMetadataLogInterval)
    tm.replicationBatchSize = DefaultReplicationBatchSize
    return tm, nil
}

coord is *cluster.Cluster in production, or an in-memory fake in tests (tests.FakeTopicCoordinator) — NewTopicManager just takes it directly as a constructor argument. periodicLog runs in the background for the lifetime of the manager, logging local LEOs every 30 seconds for debugging.

Step 2: Define the TopicCoordinator interface

Interface for everything TopicManager needs from the cluster

broker/topic/topic_coordinator.go defines exactly what TopicManager needs — metadata queries, metadata mutation, and cluster/Raft state — as one interface, implemented by *cluster.Cluster in production:

// broker/topic/topic_coordinator.go
package topic

type TopicCoordinator interface {
    // — metadata queries (read-only) —
    TopicExists(topic string) bool
    TopicNames() []string
    TopicInfo(topic string) (info protocol.TopicInfo, ok bool)
    TopicLeaderNodeID(topic string) (leaderNodeID string, ok bool)
    TopicHasReplica(topic, nodeID string) bool
    TopicMinISRLeo(topic string, localLEO uint64) uint64
    NodeIDWithLeastTopics(candidateNodeIDs []string) (string, error)

    // — metadata mutation (Raft-applied; only take effect when called on the Raft leader) —
    ApplyCreateTopicEvent(topic string, replicaCount uint32, leaderNodeID string, replicaNodeIds []string) error
    ApplyDeleteTopicEventInternal(topic string) error
    ApplyIsrUpdateEventInternal(topic, replicaNodeID string, isr bool) error
    ApplyLeaderChangeEvent(topic, leaderNodeID string, leaderEpoch int64) error
    RecordReplicaFetch(topic, replicaNodeID string, leo int64, lagThreshold uint64, localLEO uint64) (isr bool, ok bool)

    // — cluster/raft state —
    IsLeader() bool
    GetRaftLeaderNodeID() (string, error)
    AliveNodeIDs() []string
    NodeRPCAddr(nodeID string) (string, bool)
    IsNodeAlive(nodeID string) bool
}

Defining this as one interface means TopicManager never touches cluster-internal storage directly — it's a lean consumer of cluster state rather than an owner of it. That, in turn, means tests can swap in tests.FakeTopicCoordinator (an in-memory implementation that still exercises the real cluster.ClusterMetadataStore.Apply logic) without spinning up Raft or Serf at all.

Step 3: Replica placement policy

PickReplicaNodeIds

Picking which nodes host a new topic's replicas is a small, pure function with no cluster state involved, so it lives in its own file rather than as a TopicManager method or behind TopicCoordinator:

// broker/topic/placement.go
package topic

const DefaultISRLagThreshold = uint64(100)

func PickReplicaNodeIds(leaderNodeID string, replicaCount int, candidateNodeIDs []string) ([]string, error) {
    var others []string
    for _, id := range candidateNodeIDs {
        if id != leaderNodeID {
            others = append(others, id)
        }
    }
    if len(others) < replicaCount {
        return nil, ErrNotEnoughNodesf(replicaCount, len(others))
    }
    return others[:replicaCount], nil
}

Least-loaded leader selection (with lexicographic tie-breaking) lives on cluster.ClusterMetadataStore.NodeIDWithLeastTopics instead, since it needs to inspect every topic's current leader — state that belongs to the cluster package. TopicManager.CreateTopic calls it through the TopicCoordinator interface (tm.coordinator.NodeIDWithLeastTopics(candidates)) rather than owning the logic itself.

Step 4: Create and delete topics

CreateTopic

// broker/topic/topic.go
func (tm *TopicManager) CreateTopic(ctx context.Context, req *protocol.CreateTopicRequest) (*protocol.CreateTopicResponse, error) {
    c := tm.coordinator
    if !c.IsLeader() {
        return nil, fmt.Errorf("create topic must be sent to Raft leader: %w", ErrCannotReachLeader)
    }
    if c.TopicExists(req.Topic) {
        return nil, ErrTopicExistsf(req.Topic)
    }
    candidates := c.AliveNodeIDs()
    leaderNodeID, err := c.NodeIDWithLeastTopics(candidates)
    if err != nil {
        return nil, err
    }
    replicaNodeIds, err := PickReplicaNodeIds(leaderNodeID, int(req.ReplicaCount), candidates)
    if err != nil {
        return nil, ErrCreateTopic(err)
    }
    tm.Logger.Info("create topic via Raft", zap.String("topic", req.Topic), zap.String("leader_node_id", leaderNodeID), zap.Strings("replica_node_ids", replicaNodeIds))
    if err := c.ApplyCreateTopicEvent(req.Topic, req.ReplicaCount, leaderNodeID, replicaNodeIds); err != nil {
        return nil, err
    }
    return &protocol.CreateTopicResponse{Topic: req.Topic, ReplicaNodeIds: replicaNodeIds}, nil
}

The flow is: pick a leader, pick replicas, propose a CreateTopicEvent through Raft, and every step reads through tm.coordinator. Crucially, this method never opens a local log — opening the leader's or a replica's log happens asynchronously, on every node, via a fast reconcile poll (Step 6).

DeleteTopic

func (tm *TopicManager) DeleteTopic(ctx context.Context, req *protocol.DeleteTopicRequest) (*protocol.DeleteTopicResponse, error) {
    c := tm.coordinator
    if !c.IsLeader() {
        return nil, fmt.Errorf("delete topic must be sent to Raft leader: %w", ErrCannotReachLeader)
    }
    if !c.TopicExists(req.Topic) {
        return nil, ErrTopicNotFoundf(req.Topic)
    }
    tm.Logger.Info("delete topic via Raft", zap.String("topic", req.Topic))
    if err := c.ApplyDeleteTopicEventInternal(req.Topic); err != nil {
        return nil, ErrApplyDeleteTopic(err)
    }
    return &protocol.DeleteTopicResponse{Topic: req.Topic}, nil
}

Same shape: check leadership and existence through the coordinator, propose the event, return. The actual removal of each node's local log directory happens in the reconcile loop once the topic disappears from cluster metadata (removeLocalTopic, Step 6) — this is what backs log.Log.Delete() from the storage-layer page.

Step 5: Look up logs and leaders

GetLog, GetLeader, IsLeader, RPC address lookups

func (tm *TopicManager) lookupTopic(name string) *log.LogManager {
    tm.mu.RLock()
    defer tm.mu.RUnlock()
    return tm.Topics[name]
}

// GetLog returns the locally-open log for topic. Returns ErrTopicNotFound both when
// topic isn't known locally at all and when its log hasn't finished opening yet
// (reconcileLocalTopics is mid-flight) — either way there's nothing usable to hand
// back yet, and callers (e.g. Produce/Fetch) already treat ErrTopicNotFound as
// retriable (see client.RetryTopicNotReady).
func (tm *TopicManager) GetLog(topic string) (*log.LogManager, error) {
    l := tm.lookupTopic(topic)
    if l == nil {
        return nil, ErrTopicNotFoundf(topic)
    }
    return l, nil
}

func (tm *TopicManager) IsLeader(topic string) (bool, error) {
    leaderID, ok := tm.coordinator.TopicLeaderNodeID(topic)
    if !ok {
        return false, ErrTopicNotFoundf(topic)
    }
    return leaderID == tm.currentNodeID(), nil
}

func (tm *TopicManager) GetTopicLeaderRPCAddr(topic string) (string, error) {
    leaderID, ok := tm.coordinator.TopicLeaderNodeID(topic)
    if !ok {
        return "", ErrTopicNotFoundf(topic)
    }
    addr, ok := tm.coordinator.NodeRPCAddr(leaderID)
    if !ok {
        return "", ErrTopicNotFoundf(topic)
    }
    return addr, nil
}

GetTopicLeaderRPCAddr is a two-step lookup: ask the cluster who leads the topic, then separately ask Serf for that node's RPC address (Cluster.NodeRPCAddr, backed by MemberLister.AliveNodeDetails() from the previous page). GetRaftLeaderRPCAddr and ListTopics follow the same pattern, delegating to tm.coordinator.GetRaftLeaderNodeID()/tm.coordinator.TopicNames()/tm.coordinator.TopicInfo() respectively — there's no local iteration over tm.Topics for anything but the log handles themselves.

Step 6: Reconcile local logs against cluster metadata

Why a reconcile loop

Cluster metadata (who leads a topic, who its replicas are) is Raft-replicated and consistent the instant it commits — but a node still needs to physically open a log file on disk for any topic it now leads or replicates. Every node runs a fast reconcile loop (50ms — see the next page on replication) that diffs cluster metadata against its own open logs and opens or closes them to match:

// broker/topic/topic.go
func (tm *TopicManager) reconcileLocalTopics() {
    names := tm.coordinator.TopicNames()
    present := make(map[string]struct{}, len(names))
    for _, name := range names {
        present[name] = struct{}{}
        tm.reconcileLocalTopic(name)
    }

    tm.mu.RLock()
    localNames := make([]string, 0, len(tm.Topics))
    for name := range tm.Topics {
        localNames = append(localNames, name)
    }
    tm.mu.RUnlock()

    for _, name := range localNames {
        if _, ok := present[name]; !ok {
            tm.removeLocalTopic(name)
        }
    }
}

// reconcileLocalTopic opens the local log for topicName if this node is its leader or a
// replica and no log is open yet. No-op if topicName isn't leader/replica-local here.
func (tm *TopicManager) reconcileLocalTopic(topicName string) {
    // Cheap check first: once a topic's log is open, steady state (the overwhelming
    // majority of calls — this runs 20x/second per topic) never needs to touch cluster
    // metadata at all.
    if tm.lookupTopic(topicName) != nil {
        return
    }
    info, ok := tm.coordinator.TopicInfo(topicName)
    if !ok {
        return
    }
    currentNodeID := tm.currentNodeID()
    isLocal := info.LeaderNodeID == currentNodeID
    if !isLocal {
        for _, r := range info.Replicas {
            if r.NodeID == currentNodeID {
                isLocal = true
                break
            }
        }
    }
    if !isLocal {
        return
    }
    logManager, err := log.NewLogManager(filepath.Join(tm.BaseDir, topicName))
    if err != nil {
        tm.Logger.Warn("open log failed", zap.String("topic", topicName), zap.Error(err))
        return
    }
    tm.publishLocalLog(topicName, logManager)
}

// removeLocalTopic closes the topic log, removes it from local bookkeeping, and deletes the topic dir.
func (tm *TopicManager) removeLocalTopic(topicName string) {
    tm.mu.Lock()
    l, ok := tm.Topics[topicName]
    if ok {
        delete(tm.Topics, topicName)
    }
    tm.mu.Unlock()
    if l != nil {
        l.Close()
        l.Delete()
    }
    _ = os.RemoveAll(filepath.Join(tm.BaseDir, topicName))
    tm.Logger.Info("topic removed", zap.String("topic", topicName))
}

This single loop covers topic creation, deletion, and leader/replica role changes uniformly: a leader-change event just changes what TopicInfo reports, and the next reconcile tick opens or closes whatever's needed to match. A node rejoining and being reassigned as a replica for an under-replicated topic is handled the same way — it's just a consequence of ClusterMetadataStore's state changing, picked up on the next tick like everything else.

One consequence worth knowing about: there's a real, bounded window — bounded by the 50ms reconcile tick, not by anything unbounded — between CreateTopic returning and a given node's log actually being open. Callers producing or consuming right after creating a topic should expect a transient "topic not found" and retry (this is exactly what client.RetryTopicNotReady, covered in the producer/consumer chapter, exists for).

RestoreFromMetadata

The one place a Raft snapshot restore needs a TopicManager-side action is opening local logs for whatever cluster metadata a Restore() populated:

func (tm *TopicManager) RestoreFromMetadata() error {
    currentNodeID := tm.currentNodeID()
    topicNames := tm.coordinator.TopicNames()
    for _, topicName := range topicNames {
        leaderID, ok := tm.coordinator.TopicLeaderNodeID(topicName)
        if !ok || leaderID == "" {
            continue
        }
        if leaderID == currentNodeID {
            if err := tm.restoreLeaderTopic(topicName); err != nil {
                tm.Logger.Warn("restore leader topic failed", zap.String("topic", topicName), zap.Error(err))
                continue
            }
            // Initialize HW from local state so consumers can read immediately if no
            // replicas exist (otherwise HW stays 0 until replicas report in).
            if l := tm.lookupTopic(topicName); l != nil {
                l.SetHighWatermark(tm.coordinator.TopicMinISRLeo(topicName, l.LEO()))
            }
        } else if tm.coordinator.TopicHasReplica(topicName, currentNodeID) {
            if err := tm.restoreReplicaTopic(topicName, leaderID); err != nil {
                tm.Logger.Warn("restore replica topic failed", zap.String("topic", topicName), zap.Error(err))
                continue
            }
        }
    }
    return nil
}

In practice this and reconcileLocalTopics overlap in purpose (both open logs for topics this node should host) — RestoreFromMetadata just runs once, synchronously, right after a snapshot restore, so a restarted or newly-joined node doesn't have to wait a full reconcile tick to become useful.

Step 7: Handle produce requests

HandleProduce and HandleProduceBatch

Produce handling moved into its own file, broker/topic/producer.go, and now takes the already-resolved *log.LogManager directly instead of a *Topic wrapper:

// broker/topic/producer.go
package topic

// HandleProduce appends to the topic log (leader only). For ACK_ALL, waits for replicas to catch up.
func (tm *TopicManager) HandleProduce(ctx context.Context, topicName string, l *log.LogManager, logEntry *protocol.LogEntry, acks protocol.AckMode) (uint64, error) {
    offset, err := l.Append(logEntry.Value)
    if err != nil {
        return 0, err
    }
    tm.advanceHighWatermark(topicName, l)
    switch acks {
    case protocol.AckLeader:
        return offset, nil
    case protocol.AckAll:
        if err := tm.waitForAllFollowersToCatchUp(ctx, topicName, offset); err != nil {
            return 0, ErrWaitFollowersCatchUp(err)
        }
        return offset, nil
    default:
        return 0, ErrInvalidAckModef(int32(acks))
    }
}

HandleProduceBatch is the same shape for AppendBatch, returning both the base and last offsets written. The RPC handler (broker/rpc/producer.go, covered in the transport/RPC page) is what resolves the topic name to a *log.LogManager via TopicManager.GetLog before calling either of these.

advanceHighWatermark

Right after appending, the topic's high watermark is recomputed. This runs on every produce, not only when a replica's ISR status changes — a topic with zero replicas still needs its HW to track its own LEO, since nothing else would advance it:

func (tm *TopicManager) advanceHighWatermark(topicName string, l *log.LogManager) {
    l.SetHighWatermark(tm.coordinator.TopicMinISRLeo(topicName, l.LEO()))
}

TopicMinISRLeomin(localLEO, all in-sync replicas' LEO) — now lives on cluster.TopicMetadata (see the previous page); TopicManager just calls through the coordinator and applies the result locally.

waitForAllFollowersToCatchUp

func (tm *TopicManager) waitForAllFollowersToCatchUp(ctx context.Context, topicName string, offset uint64) error {
    timeout := time.After(5 * time.Second)
    ticker := time.NewTicker(10 * time.Millisecond)
    defer ticker.Stop()

    requiredLEO := offset + 1

    for {
        var replicas []protocol.ReplicaInfo
        if info, ok := tm.coordinator.TopicInfo(topicName); ok {
            replicas = info.Replicas
        }

        useISR := false
        for _, r := range replicas {
            if r.IsISR {
                useISR = true
                break
            }
        }

        allCaughtUp := true
        candidates := 0
        for _, replica := range replicas {
            if useISR && !replica.IsISR {
                continue
            }
            candidates++
            if uint64(replica.LEO) < requiredLEO {
                allCaughtUp = false
                break
            }
        }

        if candidates == 0 || allCaughtUp {
            return nil
        }

        select {
        case <-ticker.C:
            continue
        case <-ctx.Done():
            return ctx.Err()
        case <-timeout:
            tm.Logger.Warn("followers catch-up timeout", zap.String("topic", topicName), zap.Uint64("required_offset", offset))
            return ErrTimeoutCatchUp
        }
    }
}

The algorithm polls every 10ms, only waits on ISR replicas if any exist, and gives up after a 5-second timeout. The replica list comes from tm.coordinator.TopicInfo(topicName) — a point-in-time snapshot from cluster.TopicMetadata.Snapshot() — on every loop iteration.

Step 8: Handle node failure

ReassignLeadersForDeadNode

broker/cmd/server/helper.go registers this method as Cluster's on-node-removed callback (Cluster.SetOnNodeRemoved, from the previous page), so it fires whenever the Raft peer-change watcher observes a voter drop out:

// broker/topic/topic.go
func (tm *TopicManager) ReassignLeadersForDeadNode(nodeID string) {
    if !tm.coordinator.IsLeader() {
        return
    }
    type leaderChange struct {
        topic     string
        newLeader string
        epoch     int64
    }
    var changes []leaderChange
    for _, topicName := range tm.coordinator.TopicNames() {
        info, ok := tm.coordinator.TopicInfo(topicName)
        if !ok || info.LeaderNodeID != nodeID {
            continue
        }
        var newLeader string
        for _, rs := range info.Replicas {
            if rs.NodeID == nodeID || !rs.IsISR {
                continue
            }
            if !tm.coordinator.IsNodeAlive(rs.NodeID) {
                continue
            }
            newLeader = rs.NodeID
            break
        }
        if newLeader == "" {
            tm.Logger.Warn("no ISR replica for leadership", zap.String("topic", topicName), zap.String("old_leader_node_id", nodeID))
            continue
        }
        changes = append(changes, leaderChange{topic: topicName, newLeader: newLeader, epoch: info.LeaderEpoch + 1})
    }
    if len(changes) == 0 {
        return
    }
    // Applied asynchronously so the caller (e.g. the reconciliation loop) isn't
    // blocked for the duration of the resulting Raft round-trips.
    go func() {
        for _, ch := range changes {
            if err := tm.coordinator.ApplyLeaderChangeEvent(ch.topic, ch.newLeader, ch.epoch); err != nil {
                tm.Logger.Warn("leader change apply failed", zap.String("topic", ch.topic), zap.Error(err))
            }
        }
    }()
}

Only ISR replicas are eligible for the new leadership, the new leader's epoch is oldEpoch + 1, and only the current Raft leader actually proposes anything — every other node's callback fires too but no-ops, since only the Raft leader can successfully commit the resulting event anyway. The go func() exists purely so the caller (the peer-change watcher goroutine) isn't blocked for the duration of the resulting Raft round-trips.

Step 9: Understand the end-to-end integration

Complete workflow from request to open log

Client: CreateTopicRequest
    │
    ▼
RPC handler → TopicManager.CreateTopic()
    │
    ├── coordinator.AliveNodeIDs() / NodeIDWithLeastTopics() → picks leader
    ├── PickReplicaNodeIds() → picks replicas
    │
    ▼
Cluster.ApplyCreateTopicEvent(topic, replicaCount, leader, replicas)
    │
    ▼
Raft replicates to majority; ClusterMetadataStore.Apply() updates state on ALL nodes
    │
    ▼
Each node's reconcile loop (next tick, ≤50ms later)
    │
    └── reconcileLocalTopic() opens a local log if this node is leader or replica

For produce:

Client: ProduceRequest (topic, value, acks=AckAll)
    │
    ▼
RPC handler → TopicManager.GetLog(topic) → TopicManager.HandleProduce(...)
    │
    ├── l.Append(value) → writes to disk
    ├── advanceHighWatermark(topic, l)
    │
    └── waitForAllFollowersToCatchUp(topic, offset)
            │
            └── polls coordinator.TopicInfo(topic) every 10ms until:
                - all ISR replicas have LEO > offset
                - or 5s timeout

For node failure:

Node 2 crashes
    │
    ▼
Serf detects failure → discovery.Membership.handleLeave → Cluster.Leave(node2)
    │
    ▼
RaftNode.Leave → raft.RemoveServer(node2)
    │
    ▼
watchPeerChanges observes the voter removal → onNodeRemoved(node2)
    │
    ▼
TopicManager.ReassignLeadersForDeadNode(node2)
    │
    └── go func() { Cluster.ApplyLeaderChangeEvent(topic, newLeader, epoch+1) }

Summary

Component Purpose
TopicManager (broker/topic/topic.go) Local-only state: map[string]*log.LogManager of open logs on this node.
TopicCoordinator (topic_coordinator.go) Interface for every cluster-metadata query/mutation and cluster-state query TopicManager needs. Implemented by *cluster.Cluster; faked in tests.
PickReplicaNodeIds (placement.go) Pure replica-placement policy — no cluster state involved.
CreateTopic / DeleteTopic Pick leader/replicas (or validate existence), propose the event through the coordinator. Never open/close a log directly.
reconcileLocalTopics Runs on a fast poll (see the next page), diffing cluster metadata against locally-open logs — opens logs for newly-assigned leader/replica roles, closes and deletes logs for removed topics.
HandleProduce / HandleProduceBatch (producer.go) Append to the log, advance HW, and for AckAll block on waitForAllFollowersToCatchUp — reading replica state from coordinator.TopicInfo.
ReassignLeadersForDeadNode Wired as Cluster's on-node-removed callback. Only ISR replicas are eligible for the new leadership; only the Raft leader's proposal actually takes effect.

With topic management in place, the next page covers replication — how the reconcile loop and the replicate loop run side by side, and how follower replicas pull data from the leader.