Hello! Welcome back to our hands-on course building a simplified Kafka-like message broker in Go.
In our last lesson, we designed the core data structures that will represent the state of our consumer groups. We created the ConsumerGroup and ConsumerMember structs, introduced the concept of a GenerationID for consistency, and defined an OffsetManager interface for durable state tracking. We designed the "what"—the static model of our system.
Introduction to the Lesson
Today, we dive into the dynamic behavior of consumer groups. The learning outcome for this lesson is to implement the consumer group join and leave protocol for dynamic membership. This is where our broker starts to feel alive, responding to clients that want to participate in consuming data.
We will implement the network logic that allows a consumer to dynamically join a group to start processing messages, and to leave a group when it shuts down gracefully. These actions are the primary triggers for a "rebalance," the process of redistributing partitions among the members of a group.
Specifically, we will cover:
- The high-level mechanics of the rebalance protocol, which consists of
JoinGroupandSyncGroupphases. - Implementing the handler for a
JoinGrouprequest, which adds a member to a group and elects a leader. - Implementing the handler for a
LeaveGrouprequest, which removes a member and initiates a rebalance. - Managing the state of the group as it transitions between
StableandRebalancing.
Let's begin by understanding the protocol that governs this dynamic membership.
1. The Rebalance Protocol: A Two-Phase Dance
When the membership of a consumer group changes, the broker must coordinate a redistribution of topic partitions. This process is called a rebalance. In Kafka-like systems, this is a carefully orchestrated two-phase protocol involving all members of the group.
To get a solid conceptual foundation, let's watch a video that explains this process.
Apache Kafka® Consumers and Consumer Group Protocol
This segment from Confluent's 'Apache Kafka® Consumers and Consumer Group Protocol' video details how consumers join a group and how the protocol works, including the roles of the Group Coordinator and Group Leader.
Watch from 01:46 to 04:16. Focus on the sequence of events: A consumer sends a JoinGroup request. The coordinator elects one member as the Group Leader. The leader receives the full member list and computes the assignments. The leader sends the assignments back in a SyncGroup request. The coordinator distributes the assignments to all members.
As the video illustrates, the rebalance protocol consists of two main requests from the consumer's perspective: JoinGroup and SyncGroup.
- Phase 1:
JoinGroup: All members of the group "check in" with the Group Coordinator. The coordinator waits until it has heard from all members, elects a leader, and then sends a response. The leader's response contains the full list of members, while other members are simply told who the leader is.

- Phase 2:
SyncGroup: The leader, having received the list of members, computes the partition assignments. It sends these assignments to the coordinator in aSyncGrouprequest. All other members also send aSyncGrouprequest, but theirs is empty. The coordinator then disseminates the assignment plan to each respective member in theSyncGroupresponse.

This two-phase process acts as a distributed barrier, ensuring that all members stop processing with their old assignments and wait until everyone has received their new assignments before proceeding.
Today, we will implement the JoinGroup and LeaveGroup handlers. The LeaveGroup request is a trigger for this process, and the JoinGroup request is the first phase. We'll leave the implementation of the SyncGroup handler and the assignment algorithm itself for the next lesson.
2. Augmenting the ConsumerGroup State
In the last lesson, we designed the ConsumerGroup struct. To manage the rebalance protocol, we need to add one more field: a state flag. This will help our coordinator know whether the group is operating normally or is in the middle of a rebalance.
Let's add a State field to the ConsumerGroup struct we defined previously.
// Add these definitions to your group management package
type GroupState int
const (
// Stable is the state where the group is active and all members are processing.
Stable GroupState = iota
// Rebalancing is the state where the group is undergoing a rebalance.
// Members are re-joining and partition assignments are being recalculated.
Rebalancing
)
// Update your ConsumerGroup struct from the previous lesson
type ConsumerGroup struct {
mu sync.RWMutex
GroupID string
State GroupState // New field
GenerationID int
LeaderID string
Members map[string]*ConsumerMember
Assignments map[string][]TopicPartition
}
When a member joins or leaves, we will transition the group's state to Rebalancing and increment the GenerationID. Once the rebalance is complete (after the SyncGroup phase), we'll transition it back to Stable.
3. Implementing the JoinGroup Protocol
The JoinGroup request is the entry point for a consumer into a group. Our coordinator needs a handler for this request that manages the group's state according to the protocol.
First, let's define the request and response structures. These will be part of the binary protocol you designed in Module 5, but for now, we'll represent them as Go structs.
// Part of your broker's internal API
type JoinGroupRequest struct {
GroupID string
MemberID string // Empty for a new member, non-empty for a re-joining member
SubscribedTopics []string
SessionTimeout time.Duration
}
type JoinGroupResponse struct {
Error error
GenerationID int
MemberID string
LeaderID string
Members map[string]*ConsumerMember // Only sent to the leader
}
Now, let's outline the logic for the handleJoinGroup method on the Coordinator struct.
// In your Coordinator implementation
func (c *Coordinator) handleJoinGroup(req *JoinGroupRequest) *JoinGroupResponse {
c.mu.Lock()
group, exists := c.groups[req.GroupID]
if !exists {
group = c.createNewGroup(req.GroupID) // Assume this helper creates a new ConsumerGroup
c.groups[req.GroupID] = group
}
c.mu.Unlock()
group.mu.Lock()
defer group.mu.Unlock()
// --- Core Join Logic ---
// 1. If the group is stable and a new member joins, start a rebalance.
isNewMember := req.MemberID == ""
if group.State == Stable && isNewMember {
group.State = Rebalancing
group.GenerationID++
// In a real implementation, you'd notify existing members to rejoin.
// For our simplified model, we assume they will poll and discover the new generation.
}
// 2. Add the new member to the group.
newMemberID := req.MemberID
if isNewMember {
newMemberID = generateMemberID() // A function to create a unique ID
}
member := &ConsumerMember{
ID: newMemberID,
GroupID: req.GroupID,
SubscribedTopics: req.SubscribedTopics,
SessionTimeout: req.SessionTimeout,
LastHeartbeat: time.Now(),
}
group.Members[newMemberID] = member
// 3. Elect a leader. For simplicity, the first member to join becomes the leader.
if group.LeaderID == "" {
group.LeaderID = newMemberID
}
// In a complete implementation, the coordinator would wait here for all members
// to send their JoinGroup requests before replying.
// For this lesson, we will reply immediately, which works for a single-member join.
// The "double barrier" logic would be added here to handle multi-member rebalances.
// 4. Construct the response.
resp := &JoinGroupResponse{
GenerationID: group.GenerationID,
MemberID: newMemberID,
LeaderID: group.LeaderID,
}
// 5. The leader gets the full member list to perform the assignment.
if newMemberID == group.LeaderID {
resp.Members = group.Members
}
return resp
}
This implementation covers the essential state transitions for a new member joining a group. To go deeper into the protocol details, the following resource is invaluable. It provides a text-based deep-dive that complements the video.
The Unofficial Kafka Rebalance How-To
The article 'The Unofficial Kafka Rebalance How-To' offers a fantastically detailed breakdown of the rebalance protocol. We'll focus on the sections covering why rebalances happen and the specifics of the JoinGroup request.
Read the sections 'When a member joins or leaves a consumer group' and 'Join or rejoin the consumer group'. Pay special attention to the description of the JoinGroup request fields (like member ID and session timeout) and the response fields (generation ID, leader ID, etc.). This will clarify the 'why' behind the fields in our Go structs.
4. Implementing the LeaveGroup Protocol
Leaving a group is a more straightforward process. The consumer sends a LeaveGroup request, and the coordinator removes it from the group, triggering a rebalance for the remaining members.
Here are the request and response structs:
// Part of your broker's internal API
type LeaveGroupRequest struct {
GroupID string
MemberID string
}
type LeaveGroupResponse struct {
Error error
}
The handler logic is as follows:
// In your Coordinator implementation
func (c *Coordinator) handleLeaveGroup(req *LeaveGroupRequest) *LeaveGroupResponse {
c.mu.RLock()
group, exists := c.groups[req.GroupID]
c.mu.RUnlock()
if !exists {
return &LeaveGroupResponse{Error: errors.New("group not found")}
}
group.mu.Lock()
defer group.mu.Unlock()
if _, memberExists := group.Members[req.MemberID]; !memberExists {
return &LeaveGroupResponse{Error: errors.New("member not found in group")}
}
// 1. Remove the member from the group.
delete(group.Members, req.MemberID)
// If the leader left, clear the leader ID.
if group.LeaderID == req.MemberID {
group.LeaderID = ""
// A new leader will be elected during the next JoinGroup phase.
}
// 2. If there are members left, trigger a rebalance.
if len(group.Members) > 0 {
group.State = Rebalancing
group.GenerationID++
} else {
// If the group is now empty, we can clean it up.
// For simplicity, we'll just reset its state.
group.State = Stable
group.GenerationID = 0
}
return &LeaveGroupResponse{Error: nil}
}
This logic cleanly handles member departure. By deleting the member and incrementing the GenerationID, you ensure that any remaining members will be forced to rejoin and get a new set of partition assignments, maintaining the integrity of the group.
Conclusion
In this lesson, we brought our consumer group design to life by implementing the core protocol for dynamic membership. We moved from static data structures to dynamic state transitions driven by client requests.
Key Takeaways:
- The consumer group protocol is a two-phase process (
JoinGroupandSyncGroup) that acts as a distributed barrier to ensure consistency during a rebalance. - A
JoinGrouprequest adds a member to a group, triggers a rebalance if necessary, and is used to elect a group leader. - A
LeaveGrouprequest allows a member to shut down gracefully, triggering a rebalance for the remaining members. - Both actions cause a state transition in the group (
Stable->Rebalancing) and an increment of theGenerationID, which invalidates old assignments. - The
GenerationIDis the key mechanism that fences stale members from the group, preventing them from interfering with outdated information.
Next Up
We have now successfully implemented the first phase of the rebalance protocol. Our coordinator can accept new members and handle departures. However, the partitions are not yet assigned. In the next lesson, we will complete the loop by implementing the rebalancing algorithm. We'll write the logic for the group leader to assign partitions to members and then build the SyncGroup handler for the coordinator to distribute this plan.