Files
renovate-sh-app[bot]GitHubrenovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
5d0e09cf8d fix(deps): update module github.com/twmb/franz-go to v1.20.5 (#6049)
| datasource | package                  | from    | to      |
| ---------- | ------------------------ | ------- | ------- |
| go         | github.com/twmb/franz-go | v1.20.4 | v1.20.5 |

Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
2025-12-10 08:30:02 +01:00

960 lines
34 KiB
Go

package kgo
import (
"bytes"
"fmt"
"slices"
"sort"
"strings"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kgo/internal/sticky"
"github.com/twmb/franz-go/pkg/kmsg"
)
// GroupBalancer balances topics and partitions among group members.
//
// A GroupBalancer is roughly equivalent to Kafka's PartitionAssignor.
type GroupBalancer interface {
// ProtocolName returns the name of the protocol, e.g. roundrobin,
// range, sticky.
ProtocolName() string
// JoinGroupMetadata returns the metadata to use in JoinGroup, given
// the topic interests and the current assignment and group generation.
//
// It is safe to modify the input topics and currentAssignment. The
// input topics are guaranteed to be sorted, as are the partitions for
// each topic in currentAssignment. It is recommended for your output
// to be ordered by topic and partitions. Since Kafka uses the output
// from this function to determine whether a rebalance is needed, a
// deterministic output will avoid accidental rebalances.
JoinGroupMetadata(
topicInterests []string,
currentAssignment map[string][]int32,
generation int32,
) []byte
// ParseSyncAssignment returns assigned topics and partitions from an
// encoded SyncGroupResponse's MemberAssignment.
ParseSyncAssignment(assignment []byte) (map[string][]int32, error)
// MemberBalancer returns a GroupMemberBalancer for the given group
// members, as well as the topics that all the members are interested
// in. If the client does not have some topics in the returned topics,
// the client issues a metadata request to load the number of
// partitions in those topics before calling the GroupMemberBalancer's
// Balance function.
//
// The input group members are guaranteed to be sorted first by
// instance ID, if non-nil, and then by member ID.
//
// It is up to the user to decide how to decode each member's
// ProtocolMetadata field. The default client group protocol of
// "consumer" by default uses join group metadata's of type
// kmsg.ConsumerMemberMetadata. If this is the case for you, it may be
// useful to use the ConsumerBalancer type to help parse the metadata
// and balance.
//
// If the member metadata cannot be deserialized correctly, this should
// return a relevant error.
MemberBalancer(members []kmsg.JoinGroupResponseMember) (b GroupMemberBalancer, topics map[string]struct{}, err error)
// IsCooperative returns if this is a cooperative balance strategy.
IsCooperative() bool
}
// GroupMemberBalancer balances topics amongst group members. If your balancing
// can fail, you can implement GroupMemberBalancerOrError.
type GroupMemberBalancer interface {
// Balance balances topics and partitions among group members, where
// the int32 in the topics map corresponds to the number of partitions
// known to be in each topic.
Balance(topics map[string]int32) IntoSyncAssignment
}
// GroupMemberBalancerOrError is an optional extension interface for
// GroupMemberBalancer. This can be implemented if your balance function can
// fail.
//
// For interface purposes, it is required to implement GroupMemberBalancer, but
// Balance will never be called.
type GroupMemberBalancerOrError interface {
GroupMemberBalancer
BalanceOrError(topics map[string]int32) (IntoSyncAssignment, error)
}
// IntoSyncAssignment takes a balance plan and returns a list of assignments to
// use in a kmsg.SyncGroupRequest.
//
// It is recommended to ensure the output is deterministic and ordered by
// member / topic / partitions.
type IntoSyncAssignment interface {
IntoSyncAssignment() []kmsg.SyncGroupRequestGroupAssignment
}
// ConsumerBalancer is a helper type for writing balance plans that use the
// "consumer" protocol, such that each member uses a kmsg.ConsumerMemberMetadata
// in its join group request.
type ConsumerBalancer struct {
b ConsumerBalancerBalance
members []kmsg.JoinGroupResponseMember
metadatas []kmsg.ConsumerMemberMetadata
topics map[string]struct{}
err error
}
// Balance satisfies the GroupMemberBalancer interface, but is never called
// because GroupMemberBalancerOrError exists.
func (*ConsumerBalancer) Balance(map[string]int32) IntoSyncAssignment {
panic("unreachable")
}
// BalanceOrError satisfies the GroupMemberBalancerOrError interface.
func (b *ConsumerBalancer) BalanceOrError(topics map[string]int32) (IntoSyncAssignment, error) {
return b.b.Balance(b, topics), b.err
}
// Members returns the list of input members for this group balancer.
func (b *ConsumerBalancer) Members() []kmsg.JoinGroupResponseMember {
return b.members
}
// EachMember calls fn for each member and its corresponding metadata in the
// consumer group being balanced.
func (b *ConsumerBalancer) EachMember(fn func(member *kmsg.JoinGroupResponseMember, meta *kmsg.ConsumerMemberMetadata)) {
for i := range b.members {
fn(&b.members[i], &b.metadatas[i])
}
}
// MemberAt returns the nth member and its corresponding metadata.
func (b *ConsumerBalancer) MemberAt(n int) (*kmsg.JoinGroupResponseMember, *kmsg.ConsumerMemberMetadata) {
return &b.members[n], &b.metadatas[n]
}
// SetError allows you to set any error that occurred while balancing. This
// allows you to fail balancing and return nil from Balance.
func (b *ConsumerBalancer) SetError(err error) {
b.err = err
}
// MemberTopics returns the unique set of topics that all members are
// interested in.
//
// This can safely be called if the balancer is nil; if so, this will return
// nil.
func (b *ConsumerBalancer) MemberTopics() map[string]struct{} {
if b == nil {
return nil
}
return b.topics
}
// NewPlan returns a type that can be used to build a balance plan. The return
// satisfies the IntoSyncAssignment interface.
func (b *ConsumerBalancer) NewPlan() *BalancePlan {
plan := make(map[string]map[string][]int32, len(b.members))
for i := range b.members {
plan[b.members[i].MemberID] = make(map[string][]int32)
}
return &BalancePlan{plan}
}
// ConsumerBalancerBalance is what the ConsumerBalancer invokes to balance a
// group.
//
// This is a complicated interface, but in short, this interface has one
// function that implements the actual balancing logic: using the input
// balancer, balance the input topics and partitions. If your balancing can
// fail, you can use ConsumerBalancer.SetError(...) to return an error from
// balancing, and then you can simply return nil from Balance.
type ConsumerBalancerBalance interface {
Balance(*ConsumerBalancer, map[string]int32) IntoSyncAssignment
}
// ParseConsumerSyncAssignment parses `assignment` as kmsg.ConsumerMemberAssignment
// and returns the mapped topic => partitions assignment.
func ParseConsumerSyncAssignment(assignment []byte) (map[string][]int32, error) {
var kassignment kmsg.ConsumerMemberAssignment
if err := kassignment.ReadFrom(assignment); err != nil {
return nil, fmt.Errorf("sync assignment parse failed: %v", err)
}
m := make(map[string][]int32, len(kassignment.Topics))
for _, topic := range kassignment.Topics {
m[topic.Topic] = topic.Partitions
}
return m, nil
}
// NewConsumerBalancer parses the each member's metadata as a
// kmsg.ConsumerMemberMetadata and returns a ConsumerBalancer to use in balancing.
//
// If any metadata parsing fails, this returns an error.
func NewConsumerBalancer(balance ConsumerBalancerBalance, members []kmsg.JoinGroupResponseMember) (*ConsumerBalancer, error) {
b := &ConsumerBalancer{
b: balance,
members: members,
metadatas: make([]kmsg.ConsumerMemberMetadata, len(members)),
topics: make(map[string]struct{}),
}
for i, member := range members {
meta := &b.metadatas[i]
meta.Default()
memberMeta := member.ProtocolMetadata
if err := meta.ReadFrom(memberMeta); err != nil {
// Some buggy clients claimed support for v1 but then
// did not add OwnedPartitions, resulting in a short
// metadata. If we fail at reading and the version is
// v1, we retry again as v0. We do not support other
// versions because hopefully other clients stop
// claiming higher and higher version support and not
// actually supporting them. Sarama has a similarish
// workaround. See #493.
if bytes.HasPrefix(memberMeta, []byte{0, 1}) {
memberMeta[0] = 0
memberMeta[1] = 0
if err = meta.ReadFrom(memberMeta); err != nil {
return nil, fmt.Errorf("unable to read member metadata: %v", err)
}
}
}
for _, topic := range meta.Topics {
b.topics[topic] = struct{}{}
}
sort.Strings(meta.Topics)
}
return b, nil
}
// BalancePlan is a helper type to build the result of balancing topics
// and partitions among group members.
type BalancePlan struct {
plan map[string]map[string][]int32 // member => topic => partitions
}
// AsMemberIDMap returns the plan as a map of member IDs to their topic &
// partition assignments.
//
// Internally, a BalancePlan is currently represented as this map. Any
// modification to the map modifies the plan. The internal representation of a
// plan may change in the future to include more metadata. If this happens, the
// map returned from this function may not represent all aspects of a plan.
// The client will attempt to mirror modifications to the map directly back
// into the underlying plan as best as possible.
func (p *BalancePlan) AsMemberIDMap() map[string]map[string][]int32 {
return p.plan
}
func (p *BalancePlan) String() string {
var sb strings.Builder
var membersWritten int
for member, topics := range p.plan {
membersWritten++
sb.WriteString(member)
sb.WriteString("{")
var topicsWritten int
for topic, partitions := range topics {
fmt.Fprintf(&sb, "%s%v", topic, partitions)
topicsWritten++
if topicsWritten < len(topics) {
sb.WriteString(", ")
}
}
sb.WriteString("}")
if membersWritten < len(p.plan) {
sb.WriteString(", ")
}
}
return sb.String()
}
// AddPartition assigns a partition for the topic to a given member.
func (p *BalancePlan) AddPartition(member *kmsg.JoinGroupResponseMember, topic string, partition int32) {
memberPlan := p.plan[member.MemberID]
memberPlan[topic] = append(memberPlan[topic], partition)
}
// AddPartitions assigns many partitions for a topic to a given member.
func (p *BalancePlan) AddPartitions(member *kmsg.JoinGroupResponseMember, topic string, partitions []int32) {
memberPlan := p.plan[member.MemberID]
memberPlan[topic] = append(memberPlan[topic], partitions...)
}
// IntoSyncAssignment satisfies the IntoSyncAssignment interface.
func (p *BalancePlan) IntoSyncAssignment() []kmsg.SyncGroupRequestGroupAssignment {
kassignments := make([]kmsg.SyncGroupRequestGroupAssignment, 0, len(p.plan))
for member, assignment := range p.plan {
var kassignment kmsg.ConsumerMemberAssignment
for topic, partitions := range assignment {
slices.Sort(partitions)
assnTopic := kmsg.NewConsumerMemberAssignmentTopic()
assnTopic.Topic = topic
assnTopic.Partitions = partitions
kassignment.Topics = append(kassignment.Topics, assnTopic)
}
sort.Slice(kassignment.Topics, func(i, j int) bool { return kassignment.Topics[i].Topic < kassignment.Topics[j].Topic })
syncAssn := kmsg.NewSyncGroupRequestGroupAssignment()
syncAssn.MemberID = member
syncAssn.MemberAssignment = kassignment.AppendTo(nil)
kassignments = append(kassignments, syncAssn)
}
sort.Slice(kassignments, func(i, j int) bool { return kassignments[i].MemberID < kassignments[j].MemberID })
return kassignments
}
func joinMemberLess(l, r *kmsg.JoinGroupResponseMember) bool {
if l.InstanceID != nil {
if r.InstanceID == nil {
return true
}
return *l.InstanceID < *r.InstanceID
}
if r.InstanceID != nil {
return false
}
return l.MemberID < r.MemberID
}
func sortJoinMembers(members []kmsg.JoinGroupResponseMember) {
sort.Slice(members, func(i, j int) bool { return joinMemberLess(&members[i], &members[j]) })
}
func sortJoinMemberPtrs(members []*kmsg.JoinGroupResponseMember) {
sort.Slice(members, func(i, j int) bool { return joinMemberLess(members[i], members[j]) })
}
func (g *groupConsumer) findBalancer(from, proto string) (GroupBalancer, error) {
for _, b := range g.cfg.balancers {
if b.ProtocolName() == proto {
return b, nil
}
}
var ours []string
for _, b := range g.cfg.balancers {
ours = append(ours, b.ProtocolName())
}
g.cl.cfg.logger.Log(LogLevelError, fmt.Sprintf("%s could not find broker-chosen balancer", from), "kafka_choice", proto, "our_set", strings.Join(ours, ", "))
return nil, fmt.Errorf("unable to balance: none of our balancers have a name equal to the balancer chosen for balancing (%s)", proto)
}
// balanceGroup returns a balancePlan from a join group response.
//
// If the group has topics this leader does not want to consume, this also
// returns all topics and partitions; the leader will then periodically do its
// own metadata update to see if partition counts have changed for these random
// topics.
func (g *groupConsumer) balanceGroup(proto string, members []kmsg.JoinGroupResponseMember, skipBalance bool) ([]kmsg.SyncGroupRequestGroupAssignment, error) {
g.cl.cfg.logger.Log(LogLevelInfo, "balancing group as leader")
b, err := g.findBalancer("balance group", proto)
if err != nil {
return nil, err
}
sortJoinMembers(members)
memberBalancer, topics, err := b.MemberBalancer(members)
if err != nil {
return nil, fmt.Errorf("unable to create group member balancer: %v", err)
}
myTopics := g.tps.load()
var needMeta bool
topicPartitionCount := make(map[string]int32, len(topics))
for topic := range topics {
data, exists := myTopics[topic]
if !exists {
needMeta = true
continue
}
topicPartitionCount[topic] = int32(len(data.load().partitions))
}
// If our consumer metadata does not contain all topics, the group is
// expressing interests in topics we are not consuming. Perhaps we have
// those topics saved in our external topics map.
if needMeta {
g.loadExternal().fn(func(m map[string]int32) {
needMeta = false
for topic := range topics {
partitions, exists := m[topic]
if !exists {
needMeta = true
continue
}
topicPartitionCount[topic] = partitions
}
})
}
if needMeta {
g.cl.cfg.logger.Log(LogLevelInfo, "group members indicated interest in topics the leader is not assigned, fetching metadata for all group topics")
var metaTopics []string
for topic := range topics {
metaTopics = append(metaTopics, topic)
}
_, resp, err := g.cl.fetchMetadataForTopics(g.ctx, false, metaTopics, nil)
if err != nil {
return nil, fmt.Errorf("unable to fetch metadata for group topics: %v", err)
}
for i := range resp.Topics {
t := &resp.Topics[i]
if t.Topic == nil {
g.cl.cfg.logger.Log(LogLevelWarn, "metadata resp in balance for topic has nil topic, skipping...", "err", kerr.ErrorForCode(t.ErrorCode))
continue
}
if t.ErrorCode != 0 {
g.cl.cfg.logger.Log(LogLevelWarn, "metadata resp in balance for topic has error, skipping...", "topic", t.Topic, "err", kerr.ErrorForCode(t.ErrorCode))
continue
}
topicPartitionCount[*t.Topic] = int32(len(t.Partitions))
}
g.initExternal(topicPartitionCount)
}
// If the returned balancer is a ConsumerBalancer (which it likely
// always will be), then we can print some useful debugging information
// about what member interests are.
if b, ok := memberBalancer.(*ConsumerBalancer); ok {
interests := new(bytes.Buffer)
b.EachMember(func(member *kmsg.JoinGroupResponseMember, meta *kmsg.ConsumerMemberMetadata) {
interests.Reset()
fmt.Fprintf(interests, "interested topics: %v, previously owned: ", meta.Topics)
for _, owned := range meta.OwnedPartitions {
slices.Sort(owned.Partitions)
fmt.Fprintf(interests, "%s%v, ", owned.Topic, owned.Partitions)
}
strInterests := interests.String()
strInterests = strings.TrimSuffix(strInterests, ", ")
if member.InstanceID == nil {
g.cl.cfg.logger.Log(LogLevelInfo, "balance group member", "id", member.MemberID, "interests", strInterests)
} else {
g.cl.cfg.logger.Log(LogLevelInfo, "balance group member", "id", member.MemberID, "instance_id", *member.InstanceID, "interests", strInterests)
}
})
} else {
g.cl.cfg.logger.Log(LogLevelInfo, "unable to log information about group member interests: the user has defined a custom balancer (not a *ConsumerBalancer)")
}
// KIP-814: we are leader and we know what the entire group is
// consuming. Crucially, we parsed topics that we are potentially not
// interested in and are now tracking them for metadata updates. We
// have logged the current interests, we do not need to actually
// balance.
if skipBalance {
switch proto := b.ProtocolName(); proto {
case RangeBalancer().ProtocolName(),
RoundRobinBalancer().ProtocolName(),
StickyBalancer().ProtocolName(),
CooperativeStickyBalancer().ProtocolName():
default:
return nil, nil
}
}
// If the returned IntoSyncAssignment is a BalancePlan, which it likely
// is if the balancer is a ConsumerBalancer, then we can again print
// more useful debugging information.
var into IntoSyncAssignment
if memberBalancerOrErr, ok := memberBalancer.(GroupMemberBalancerOrError); ok {
if into, err = memberBalancerOrErr.BalanceOrError(topicPartitionCount); err != nil {
g.cl.cfg.logger.Log(LogLevelError, "balance failed", "err", err)
return nil, err
}
} else {
into = memberBalancer.Balance(topicPartitionCount)
}
if p, ok := into.(*BalancePlan); ok {
g.cl.cfg.logger.Log(LogLevelInfo, "balanced", "plan", p.String())
} else {
g.cl.cfg.logger.Log(LogLevelInfo, "unable to log balance plan: the user has returned a custom IntoSyncAssignment (not a *BalancePlan)")
}
return into.IntoSyncAssignment(), nil
}
// helper func; range and roundrobin use v0
func simpleMemberMetadata(interests []string, generation int32) []byte {
meta := kmsg.NewConsumerMemberMetadata()
meta.Version = 3 // BUMP ME WHEN NEW FIELDS ARE ADDED, AND BUMP BELOW
meta.Topics = interests // input interests are already sorted
// meta.OwnedPartitions is nil, since simple protocols are not cooperative
meta.Generation = generation
return meta.AppendTo(nil)
}
///////////////////
// Balance Plans //
///////////////////
// RoundRobinBalancer returns a group balancer that evenly maps topics and
// partitions to group members.
//
// Suppose there are two members M0 and M1, two topics t0 and t1, and each
// topic has three partitions p0, p1, and p2. The partition balancing will be
//
// M0: [t0p0, t0p2, t1p1]
// M1: [t0p1, t1p0, t1p2]
//
// If all members subscribe to all topics equally, the roundrobin balancer
// will give a perfect balance. However, if topic subscriptions are quite
// unequal, the roundrobin balancer may lead to a bad balance. See KIP-49
// for one example (note that the fair strategy mentioned in KIP-49 does
// not exist).
//
// This is equivalent to the Java roundrobin balancer.
func RoundRobinBalancer() GroupBalancer {
return new(roundRobinBalancer)
}
type roundRobinBalancer struct{}
func (*roundRobinBalancer) ProtocolName() string { return "roundrobin" }
func (*roundRobinBalancer) IsCooperative() bool { return false }
func (*roundRobinBalancer) JoinGroupMetadata(interests []string, _ map[string][]int32, generation int32) []byte {
return simpleMemberMetadata(interests, generation)
}
func (*roundRobinBalancer) ParseSyncAssignment(assignment []byte) (map[string][]int32, error) {
return ParseConsumerSyncAssignment(assignment)
}
func (r *roundRobinBalancer) MemberBalancer(members []kmsg.JoinGroupResponseMember) (GroupMemberBalancer, map[string]struct{}, error) {
b, err := NewConsumerBalancer(r, members)
return b, b.MemberTopics(), err
}
func (*roundRobinBalancer) Balance(b *ConsumerBalancer, topics map[string]int32) IntoSyncAssignment {
type topicPartition struct {
topic string
partition int32
}
var nparts int
for _, partitions := range topics {
nparts += int(partitions)
}
// Order all partitions available to balance, filtering out those that
// no members are subscribed to.
allParts := make([]topicPartition, 0, nparts)
for topic := range b.MemberTopics() {
for partition := int32(0); partition < topics[topic]; partition++ {
allParts = append(allParts, topicPartition{
topic,
partition,
})
}
}
sort.Slice(allParts, func(i, j int) bool {
l, r := allParts[i], allParts[j]
return l.topic < r.topic || l.topic == r.topic && l.partition < r.partition
})
plan := b.NewPlan()
// While parts are unassigned, assign them.
var memberIdx int
for len(allParts) > 0 {
next := allParts[0]
allParts = allParts[1:]
// The Java roundrobin strategy walks members circularly until
// a member can take this partition, and then starts the next
// partition where the circular iterator left off.
assigned:
for {
member, meta := b.MemberAt(memberIdx)
memberIdx = (memberIdx + 1) % len(b.Members())
for _, topic := range meta.Topics {
if topic == next.topic {
plan.AddPartition(member, next.topic, next.partition)
break assigned
}
}
}
}
return plan
}
// RangeBalancer returns a group balancer that, per topic, maps partitions to
// group members. Since this works on a topic level, uneven partitions per
// topic to the number of members can lead to slight partition consumption
// disparities.
//
// Suppose there are two members M0 and M1, two topics t0 and t1, and each
// topic has three partitions p0, p1, and p2. The partition balancing will be
//
// M0: [t0p0, t0p1, t1p0, t1p1]
// M1: [t0p2, t1p2]
//
// This is equivalent to the Java range balancer.
func RangeBalancer() GroupBalancer {
return new(rangeBalancer)
}
type rangeBalancer struct{}
func (*rangeBalancer) ProtocolName() string { return "range" }
func (*rangeBalancer) IsCooperative() bool { return false }
func (*rangeBalancer) JoinGroupMetadata(interests []string, _ map[string][]int32, generation int32) []byte {
return simpleMemberMetadata(interests, generation)
}
func (*rangeBalancer) ParseSyncAssignment(assignment []byte) (map[string][]int32, error) {
return ParseConsumerSyncAssignment(assignment)
}
func (r *rangeBalancer) MemberBalancer(members []kmsg.JoinGroupResponseMember) (GroupMemberBalancer, map[string]struct{}, error) {
b, err := NewConsumerBalancer(r, members)
return b, b.MemberTopics(), err
}
func (*rangeBalancer) Balance(b *ConsumerBalancer, topics map[string]int32) IntoSyncAssignment {
topics2PotentialConsumers := make(map[string][]*kmsg.JoinGroupResponseMember)
b.EachMember(func(member *kmsg.JoinGroupResponseMember, meta *kmsg.ConsumerMemberMetadata) {
for _, topic := range meta.Topics {
topics2PotentialConsumers[topic] = append(topics2PotentialConsumers[topic], member)
}
})
plan := b.NewPlan()
for topic, potentialConsumers := range topics2PotentialConsumers {
sortJoinMemberPtrs(potentialConsumers)
numPartitions := topics[topic]
partitions := make([]int32, numPartitions)
for i := range partitions {
partitions[i] = int32(i)
}
numParts := len(partitions)
div, rem := numParts/len(potentialConsumers), numParts%len(potentialConsumers)
var consumerIdx int
for len(partitions) > 0 {
num := div
if rem > 0 {
num++
rem--
}
member := potentialConsumers[consumerIdx]
plan.AddPartitions(member, topic, partitions[:num])
consumerIdx++
partitions = partitions[num:]
}
}
return plan
}
// StickyBalancer returns a group balancer that ensures minimal partition
// movement on group changes while also ensuring optimal balancing.
//
// Suppose there are three members M0, M1, and M2, and two topics t0 and t1
// each with three partitions p0, p1, and p2. If the initial balance plan looks
// like
//
// M0: [t0p0, t0p1, t0p2]
// M1: [t1p0, t1p1, t1p2]
// M2: [t2p0, t2p2, t2p2]
//
// If M2 disappears, both roundrobin and range would have mostly destructive
// reassignments.
//
// Range would result in
//
// M0: [t0p0, t0p1, t1p0, t1p1, t2p0, t2p1]
// M1: [t0p2, t1p2, t2p2]
//
// which is imbalanced and has 3 partitions move from members that did not need
// to move (t0p2, t1p0, t1p1).
//
// RoundRobin would result in
//
// M0: [t0p0, t0p2, t1p1, t2p0, t2p2]
// M1: [t0p1, t1p0, t1p2, t2p1]
//
// which is balanced, but has 2 partitions move when they do not need to
// (t0p1, t1p1).
//
// Sticky balancing results in
//
// M0: [t0p0, t0p1, t0p2, t2p0, t2p2]
// M1: [t1p0, t1p1, t1p2, t2p1]
//
// which is balanced and does not cause any unnecessary partition movement.
// The actual t2 partitions may not be in that exact combination, but they
// will be balanced.
//
// An advantage of the sticky consumer is that it allows API users to
// potentially avoid some cleanup until after the consumer knows which
// partitions it is losing when it gets its new assignment. Users can
// then only cleanup state for partitions that changed, which will be
// minimal (see KIP-54; this client also includes the KIP-351 bugfix).
//
// Note that this API implements the sticky partitioning quite differently from
// the Java implementation. The Java implementation is difficult to reason
// about and has many edge cases that result in non-optimal balancing (albeit,
// you likely have to be trying to hit those edge cases). This API uses a
// different algorithm to ensure optimal balancing while being an order of
// magnitude faster.
//
// Since the new strategy is a strict improvement over the Java strategy, it is
// entirely compatible. Any Go client sharing a group with a Java client will
// not have its decisions undone on leadership change from a Go consumer to a
// Java one. Java balancers do not apply the strategy it comes up with if it
// deems the balance score equal to or worse than the original score (the score
// being effectively equal to the standard deviation of the mean number of
// assigned partitions). This Go sticky balancer is optimal and extra sticky.
// Thus, the Java balancer will never back out of a strategy from this
// balancer.
func StickyBalancer() GroupBalancer {
return &stickyBalancer{cooperative: false}
}
type stickyBalancer struct {
cooperative bool
}
func (s *stickyBalancer) ProtocolName() string {
if s.cooperative {
return "cooperative-sticky"
}
return "sticky"
}
func (s *stickyBalancer) IsCooperative() bool { return s.cooperative }
func (s *stickyBalancer) JoinGroupMetadata(interests []string, currentAssignment map[string][]int32, generation int32) []byte {
meta := kmsg.NewConsumerMemberMetadata()
meta.Version = 3 // BUMP ME WHEN NEW FIELDS ARE ADDED, AND BUMP ABOVE
meta.Topics = interests
meta.Generation = generation
stickyMeta := kmsg.NewStickyMemberMetadata()
stickyMeta.Generation = generation
for topic, partitions := range currentAssignment {
if s.cooperative {
metaPart := kmsg.NewConsumerMemberMetadataOwnedPartition()
metaPart.Topic = topic
metaPart.Partitions = partitions
meta.OwnedPartitions = append(meta.OwnedPartitions, metaPart)
}
stickyAssn := kmsg.NewStickyMemberMetadataCurrentAssignment()
stickyAssn.Topic = topic
stickyAssn.Partitions = partitions
stickyMeta.CurrentAssignment = append(stickyMeta.CurrentAssignment, stickyAssn)
}
// KAFKA-12898: ensure our topics are sorted
metaOwned := meta.OwnedPartitions
stickyCurrent := stickyMeta.CurrentAssignment
sort.Slice(metaOwned, func(i, j int) bool { return metaOwned[i].Topic < metaOwned[j].Topic })
sort.Slice(stickyCurrent, func(i, j int) bool { return stickyCurrent[i].Topic < stickyCurrent[j].Topic })
meta.UserData = stickyMeta.AppendTo(nil)
return meta.AppendTo(nil)
}
func (*stickyBalancer) ParseSyncAssignment(assignment []byte) (map[string][]int32, error) {
return ParseConsumerSyncAssignment(assignment)
}
func (s *stickyBalancer) MemberBalancer(members []kmsg.JoinGroupResponseMember) (GroupMemberBalancer, map[string]struct{}, error) {
b, err := NewConsumerBalancer(s, members)
return b, b.MemberTopics(), err
}
func (s *stickyBalancer) Balance(b *ConsumerBalancer, topics map[string]int32) IntoSyncAssignment {
// Since our input into balancing is already sorted by instance ID,
// the sticky strategy does not need to worry about instance IDs at all.
// See my (slightly rambling) comment on KAFKA-8432.
stickyMembers := make([]sticky.GroupMember, 0, len(b.Members()))
b.EachMember(func(member *kmsg.JoinGroupResponseMember, meta *kmsg.ConsumerMemberMetadata) {
stickyMembers = append(stickyMembers, sticky.GroupMember{
ID: member.MemberID,
Topics: meta.Topics,
UserData: meta.UserData,
Owned: meta.OwnedPartitions,
Generation: meta.Generation,
Cooperative: s.cooperative,
})
})
p := &BalancePlan{sticky.Balance(stickyMembers, topics)}
if s.cooperative {
p.AdjustCooperative(b)
}
return p
}
// CooperativeStickyBalancer performs the sticky balancing strategy, but
// additionally opts the consumer group into "cooperative" rebalancing.
//
// Cooperative rebalancing differs from "eager" (the original) rebalancing in
// that group members do not stop processing partitions during the rebalance.
// Instead, once they receive their new assignment, each member determines
// which partitions it needs to revoke. If any, they send a new join request
// (before syncing), and the process starts over. This should ultimately end up
// in only two join rounds, with the major benefit being that processing never
// needs to stop.
//
// NOTE once a group is collectively using cooperative balancing, it is unsafe
// to have a member join the group that does not support cooperative balancing.
// If the only-eager member is elected leader, it will not know of the new
// multiple join strategy and things will go awry. Thus, once a group is
// entirely on cooperative rebalancing, it cannot go back.
//
// Migrating an eager group to cooperative balancing requires two rolling
// bounce deploys. The first deploy should add the cooperative-sticky strategy
// as an option (that is, each member goes from using one balance strategy to
// two). During this deploy, Kafka will tell leaders to continue using the old
// eager strategy, since the old eager strategy is the only one in common among
// all members. The second rolling deploy removes the old eager strategy. At
// this point, Kafka will tell the leader to use cooperative-sticky balancing.
// During this roll, all members in the group that still have both strategies
// continue to be eager and give up all of their partitions every rebalance.
// However, once a member only has cooperative-sticky, it can begin using this
// new strategy and things will work correctly. See KIP-429 for more details.
func CooperativeStickyBalancer() GroupBalancer {
return &stickyBalancer{cooperative: true}
}
// AdjustCooperative performs the final adjustment to a plan for cooperative
// balancing.
//
// Over the plan, we remove all partitions that migrated from one member (where
// it was assigned) to a new member (where it is now planned).
//
// This allows members that had partitions removed to revoke and rejoin, which
// will then do another rebalance, and in that new rebalance, the planned
// partitions are now on the free list to be assigned.
func (p *BalancePlan) AdjustCooperative(b *ConsumerBalancer) {
allAdded := make(map[string]map[int32]string, 100) // topic => partition => member
allRevoked := make(map[string]map[int32]struct{}, 100)
addT := func(t string) map[int32]string {
addT := allAdded[t]
if addT == nil {
addT = make(map[int32]string, 20)
allAdded[t] = addT
}
return addT
}
revokeT := func(t string) map[int32]struct{} {
revokeT := allRevoked[t]
if revokeT == nil {
revokeT = make(map[int32]struct{}, 20)
allRevoked[t] = revokeT
}
return revokeT
}
tmap := make(map[string]struct{}) // reusable topic existence map
pmap := make(map[int32]struct{}) // reusable partitions existence map
plan := p.plan
// First, on all members, we find what was added and what was removed
// to and from that member.
b.EachMember(func(member *kmsg.JoinGroupResponseMember, meta *kmsg.ConsumerMemberMetadata) {
planned := plan[member.MemberID]
// added := planned - current
// revoked := current - planned
for ptopic := range planned { // set existence for all planned topics
tmap[ptopic] = struct{}{}
}
for _, otopic := range meta.OwnedPartitions { // over all prior owned topics,
topic := otopic.Topic
delete(tmap, topic)
ppartitions, exists := planned[topic]
if !exists { // any topic that is no longer planned was entirely revoked,
allRevokedT := revokeT(topic)
for _, opartition := range otopic.Partitions {
allRevokedT[opartition] = struct{}{}
}
continue
}
// calculate what was added by creating a planned existence map,
// then removing what was owned, and anything that remains is new,
for _, ppartition := range ppartitions {
pmap[ppartition] = struct{}{}
}
for _, opartition := range otopic.Partitions {
delete(pmap, opartition)
}
if len(pmap) > 0 {
allAddedT := addT(topic)
for ppartition := range pmap {
delete(pmap, ppartition)
allAddedT[ppartition] = member.MemberID
}
}
// then calculate removal by creating owned existence map,
// then removing what was planned, anything remaining was revoked.
for _, opartition := range otopic.Partitions {
pmap[opartition] = struct{}{}
}
for _, ppartition := range ppartitions {
delete(pmap, ppartition)
}
if len(pmap) > 0 {
allRevokedT := revokeT(topic)
for opartition := range pmap {
delete(pmap, opartition)
allRevokedT[opartition] = struct{}{}
}
}
}
for ptopic := range tmap { // finally, anything remaining in tmap is a new planned topic.
delete(tmap, ptopic)
allAddedT := addT(ptopic)
for _, ppartition := range planned[ptopic] {
allAddedT[ppartition] = member.MemberID
}
}
})
// Over all revoked, if the revoked partition was added to a different
// member, we remove that partition from the new member.
for topic, rpartitions := range allRevoked {
atopic, exists := allAdded[topic]
if !exists {
continue
}
for rpartition := range rpartitions {
amember, exists := atopic[rpartition]
if !exists {
continue
}
ptopics := plan[amember]
ppartitions := ptopics[topic]
for i, ppartition := range ppartitions {
if ppartition == rpartition {
ppartitions[i] = ppartitions[len(ppartitions)-1]
ppartitions = ppartitions[:len(ppartitions)-1]
break
}
}
if len(ppartitions) > 0 {
ptopics[topic] = ppartitions
} else {
delete(ptopics, topic)
}
}
}
}