mirror of
https://github.com/hanzoai/traces.git
synced 2026-10-30 18:20:41 +00:00
| 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>
960 lines
34 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|