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

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>
2026-01-07 23:39:49 +00:00

3000 lines
98 KiB
Go

package kgo
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kmsg"
)
type groupConsumer struct {
c *consumer // used to change consumer state; generally c.mu is grabbed on access
cl *Client // used for running requests / adding to topics map
cfg *cfg
ctx context.Context
cancel func()
manageDone chan struct{} // closed once when the manage goroutine quits
cooperative atomic.Bool // true if the group balancer chosen during Join is cooperative
// The data for topics that the user assigned. Metadata updates the
// atomic.Value in each pointer atomically.
//
// We initialize tps with zero-value *topicPartitions in initGroup
// if we are directly consuming topics. If we are regex consuming,
// the metadata loop itself requests all topics, filters the topics
// against our regex, and then adds matching topics to tps.
//
// This, effectively, is the set of all candidate topics we could
// theoretically consume. Purging topics removes from this.
tps *topicsPartitions
reSeen map[string]bool // topics we evaluated against regex, and whether we want them or not
// Full lock grabbed in CommitOffsetsSync, read lock grabbed in
// CommitOffsets, this lock ensures that only one sync commit can
// happen at once, and if it is happening, no other commit can be
// happening.
syncCommitMu sync.RWMutex
rejoinCh chan string // cap 1; sent to if subscription changes (regex)
// For EOS, before we commit, we force a heartbeat. If the client and
// group member are both configured properly, then the transactional
// timeout will be less than the session timeout. By forcing a
// heartbeat before the commit, if the heartbeat was successful, then
// we ensure that we will complete the transaction within the group
// session, meaning we will not commit after the group has rebalanced.
heartbeatForceCh chan func(error)
// The following two are only updated in the manager / join&sync loop
// The nowAssigned map is read when commits fail: if the commit fails
// with ILLEGAL_GENERATION and it contains only partitions that are in
// nowAssigned, we re-issue.
lastAssigned map[string][]int32
nowAssigned amtps
// Fetching ensures we continue fetching offsets across cooperative
// rebalance if an offset fetch returns early due to an immediate
// rebalance. See the large comment on adjustCooperativeFetchOffsets
// for more details.
//
// This is modified only in that function, or in the manage loop on a
// hard error once the heartbeat/fetch has returned.
fetching map[string]map[int32]struct{}
// onFetchedMu ensures we do not call onFetched nor adjustOffsets
// concurrent with onRevoked.
//
// The group session itself ensures that OnPartitions functions are
// serial, but offset fetching is concurrent with heartbeating and can
// finish before or after heartbeating has already detected a revoke.
// To make user lives easier, we guarantee that offset fetch callbacks
// cannot be concurrent with onRevoked with this mu. If fetch callbacks
// are present, we hook this mu into onRevoked, and we grab it in the
// locations fetch callbacks are called. We only have to worry about
// onRevoked because fetching offsets occurs after onAssigned, and
// onLost happens after fetching offsets is done.
onFetchedMu sync.Mutex
// leader is whether we are the leader right now. This is set to false
//
// - set to false at the beginning of a join group session
// - set to true if join group response indicates we are leader
// - read on metadata updates in findNewAssignments
leader atomic.Bool
// Set to true when ending a transaction committing transaction
// offsets, and then set to false immediately after before calling
// EndTransaction.
offsetsAddedToTxn bool
// If we are leader, then other members may express interest to consume
// topics that we are not interested in consuming. We track the entire
// group's topics in external, and our fetchMetadata loop uses this.
// We store this as a pointer for address comparisons.
// Not relevant if using KIP-848.
external atomic.Value // *groupExternal
// See the big comment on `commit`. If we allow committing between
// join&sync, we occasionally see RebalanceInProgress or
// IllegalGeneration errors while cooperative consuming.
// Not relevant if using KIP-848.
noCommitDuringJoinAndSync sync.RWMutex
//////////////
// mu block //
//////////////
mu sync.Mutex
// using is updated when finding new assignments, we always add to this
// if we want to consume a topic (or see there are more potential
// partitions). The difference between 'using' and 'tps' is that
// 'using' is used FOR joining. We add topics to this when we learn
// about them and want to consume them, and the topics here
// are used in the JoinGroup metadata. There may be a small delta
// between 'tps' before topics are in 'using', and 'using' tracks
// the last known partition count for if we are leader.
//
// This is read when joining a group or leaving a group.
using map[string]int // topics *we* are currently using => # partitions known in that topic
// uncommitted is read and updated all over:
// - updated before PollFetches returns
// - updated when directly setting offsets (to rewind, for transactions)
// - emptied when leaving a group
// - updated when revoking
// - updated after fetching offsets once we receive our group assignment
// - updated after we commit
// - read when getting uncommitted or committed
uncommitted uncommitted
// memberID and generation are written to in the join and sync loop,
// and mostly read within that loop. This can be read during commits,
// which can happy any time. It is **recommended** to be done within
// the context of a group session, but (a) users may have some unique
// use cases, and (b) the onRevoke hook may take longer than a user
// expects, which would rotate a session.
memberGen groupMemberGen
// commitCancel and commitDone are set under mu before firing off an
// async commit request. If another commit happens, it cancels the
// prior commit, waits for the prior to be done, and then starts its
// own.
commitCancel func()
commitDone chan struct{}
// blockAuto is set and cleared in CommitOffsets{,Sync} to block
// autocommitting if autocommitting is active. This ensures that an
// autocommit does not cancel the user's manual commit.
blockAuto bool
// We set this once to manage the group lifecycle once.
// If we detect we should run in 848 mode, we set is848 true.
managing bool
is848 bool
g848 *g848
dying bool // set when closing, read in findNewAssignments
left chan struct{}
leaveErr error // set before left is closed
}
type groupMemberGen struct {
v atomic.Value // *groupMemberGenT
}
type groupMemberGenT struct {
memberID string
generation int32
}
func (g *groupMemberGen) memberID() string {
memberID, _ := g.load()
return memberID
}
func (g *groupMemberGen) generation() int32 {
_, generation := g.load()
return generation
}
func (g *groupMemberGen) load() (memberID string, generation int32) {
v := g.v.Load()
if v == nil {
return "", -1
}
t := v.(*groupMemberGenT)
return t.memberID, t.generation
}
func (g *groupMemberGen) store(memberID string, generation int32) {
g.v.Store(&groupMemberGenT{memberID, generation})
}
func (g *groupMemberGen) storeMember(memberID string) {
g.store(memberID, g.generation())
}
func (g *groupMemberGen) storeGeneration(generation int32) {
g.store(g.memberID(), generation)
}
// LeaveGroup leaves a group. Close automatically leaves the group, so this is
// only necessary to call if you plan to leave the group but continue to use
// the client. If a rebalance is in progress, this function waits for the
// rebalance to complete before the group can be left. This is necessary to
// allow you to safely issue one final offset commit in OnPartitionsRevoked. If
// you have overridden the default revoke, you must manually commit offsets
// before leaving the group.
//
// If you have configured the group with an InstanceID, this does not leave the
// group. With instance IDs, it is expected that clients will restart and
// re-use the same instance ID. To leave a group using an instance ID, you must
// manually issue a kmsg.LeaveGroupRequest or use an external tool (kafka
// scripts or kcl).
//
// It is recommended to use LeaveGroupContext to see if the leave was
// successful.
func (cl *Client) LeaveGroup() {
cl.LeaveGroupContext(cl.ctx)
}
// LeaveGroupContext leaves a group. Close automatically leaves the group, so this is
// only necessary to call if you plan to leave the group but continue to use
// the client. If a rebalance is in progress, this function waits for the
// rebalance to complete before the group can be left. This is necessary to
// allow you to safely issue one final offset commit in OnPartitionsRevoked. If
// you have overridden the default revoke, you must manually commit offsets
// before leaving the group.
//
// The context can be used to avoid waiting for the client to leave the group.
// Not waiting may result in your client being stuck in the group and the
// partitions this client was consuming being stuck until the session timeout.
// This function returns any leave group error or context cancel error. If the
// context is nil, this immediately leaves the group and does not wait and does
// not return an error.
//
// If you have configured the group with an InstanceID, this does not leave the
// group. With instance IDs, it is expected that clients will restart and
// re-use the same instance ID. To leave a group using an instance ID, you must
// manually issue a kmsg.LeaveGroupRequest or use an external tool (kafka
// scripts or kcl).
func (cl *Client) LeaveGroupContext(ctx context.Context) error {
c := &cl.consumer
if c.g == nil {
return nil
}
var immediate bool
if ctx == nil {
var cancel func()
ctx, cancel = context.WithCancel(context.Background())
cancel()
immediate = true
}
go func() {
c.waitAndAddRebalance()
c.mu.Lock() // lock for assign
c.assignPartitions(nil, assignInvalidateAll, nil, "invalidating all assignments in LeaveGroup")
c.g.leave(ctx)
c.mu.Unlock()
c.unaddRebalance()
}()
select {
case <-ctx.Done():
if immediate {
return nil
}
return ctx.Err()
case <-c.g.left:
return c.g.leaveErr
}
}
// GroupMetadata returns the current group member ID and generation, or an
// empty string and -1 if not in the group.
func (cl *Client) GroupMetadata() (string, int32) {
g := cl.consumer.g
if g == nil {
return "", -1
}
return g.memberGen.load()
}
func (c *consumer) initGroup() {
ctx, cancel := context.WithCancel(c.cl.ctx)
g := &groupConsumer{
c: c,
cl: c.cl,
cfg: &c.cl.cfg,
ctx: ctx,
cancel: cancel,
reSeen: make(map[string]bool),
manageDone: make(chan struct{}),
tps: newTopicsPartitions(),
rejoinCh: make(chan string, 1),
heartbeatForceCh: make(chan func(error)),
using: make(map[string]int),
left: make(chan struct{}),
}
c.g = g
if g.cfg.commitCallback == nil {
g.cfg.commitCallback = g.defaultCommitCallback
}
if g.cfg.txnID == nil {
// We only override revoked / lost if they were not explicitly
// set by options.
if g.cfg.onRevoked == nil {
g.cfg.onRevoked = g.defaultRevoke
}
// For onLost, we do not want to commit in onLost, so we
// explicitly set onLost to an empty function to avoid the
// fallback to onRevoked.
if g.cfg.onLost == nil {
g.cfg.onLost = func(context.Context, *Client, map[string][]int32) {}
}
} else {
g.cfg.autocommitDisable = true
}
for _, logOn := range []struct {
name string
set *func(context.Context, *Client, map[string][]int32)
}{
{"OnPartitionsAssigned", &g.cfg.onAssigned},
{"OnPartitionsRevoked", &g.cfg.onRevoked},
{"OnPartitionsLost", &g.cfg.onLost},
} {
user := *logOn.set
name := logOn.name
*logOn.set = func(ctx context.Context, cl *Client, m map[string][]int32) {
var ctxExpired bool
select {
case <-ctx.Done():
ctxExpired = true
default:
}
if ctxExpired {
cl.cfg.logger.Log(LogLevelDebug, "entering "+name, "with", m, "context_expired", ctxExpired)
} else {
cl.cfg.logger.Log(LogLevelDebug, "entering "+name, "with", m)
}
if user != nil {
dup := make(map[string][]int32)
for k, vs := range m {
dup[k] = slices.Clone(vs)
}
user(ctx, cl, dup)
}
}
}
if g.cfg.onFetched != nil || g.cfg.adjustOffsetsBeforeAssign != nil {
revoked := g.cfg.onRevoked
g.cfg.onRevoked = func(ctx context.Context, cl *Client, m map[string][]int32) {
g.onFetchedMu.Lock()
defer g.onFetchedMu.Unlock()
revoked(ctx, cl, m)
}
}
// For non-regex topics, we explicitly ensure they exist for loading
// metadata. This is of no impact if we are *also* consuming via regex,
// but that is no problem.
if len(g.cfg.topics) > 0 && !g.cfg.regex {
topics := make([]string, 0, len(g.cfg.topics))
for topic := range g.cfg.topics {
topics = append(topics, topic)
}
g.tps.storeTopics(topics)
}
}
func (g *groupConsumer) manageFailWait(consecutiveErrors int, err error) (ctxCanceled bool) {
// If the user has BlockPollOnRebalance enabled, we have to
// block around the onLost and assigning.
g.c.waitAndAddRebalance()
if errors.Is(err, context.Canceled) && g.cfg.onRevoked != nil {
// The cooperative consumer does not revoke everything
// while rebalancing, meaning if our context is
// canceled, we may have uncommitted data. Rather than
// diving into onLost, we should go into onRevoked,
// because for the most part, a context cancelation
// means we are leaving the group. Going into onRevoked
// gives us an opportunity to commit outstanding
// offsets. For the eager consumer, since we always
// revoke before exiting the heartbeat loop, we do not
// really care so much about *needing* to call
// onRevoked, but since we are handling this case for
// the cooperative consumer we may as well just also
// include the eager consumer.
g.cfg.onRevoked(g.cl.ctx, g.cl, g.nowAssigned.read())
} else {
// Any other error is perceived as a fatal error,
// and we go into onLost as appropriate.
if g.cfg.onLost != nil {
g.cfg.onLost(g.cl.ctx, g.cl, g.nowAssigned.read())
}
g.cfg.hooks.each(func(h Hook) {
if h, ok := h.(HookGroupManageError); ok {
h.OnGroupManageError(err)
}
})
g.c.addFakeReadyForDraining("", 0, &ErrGroupSession{err}, "notification of group management loop error")
}
// If we are eager, we should have invalidated everything
// before getting here, but we do so doubly just in case.
//
// If we are cooperative, the join and sync could have failed
// during the cooperative rebalance where we were still
// consuming. We need to invalidate everything. Waiting to
// resume from poll is necessary, but the user will likely be
// unable to commit.
{
g.c.mu.Lock()
g.c.assignPartitions(nil, assignInvalidateAll, nil, "clearing assignment at end of group management session")
g.mu.Lock() // before allowing poll to touch uncommitted, lock the group
g.c.mu.Unlock() // now part of poll can continue
g.uncommitted = nil
g.mu.Unlock()
g.nowAssigned.store(nil)
g.lastAssigned = nil
g.fetching = nil
g.leader.Store(false)
g.resetExternal()
}
// Unblock bolling now that we have called onLost and
// re-assigned.
g.c.unaddRebalance()
if errors.Is(err, context.Canceled) { // context was canceled, quit now
return true
}
// Waiting for the backoff is a good time to update our
// metadata; maybe the error is from stale metadata.
backoff := g.cfg.retryBackoff(consecutiveErrors)
g.cfg.logger.Log(LogLevelError, "group manage loop errored",
"group", g.cfg.group,
"err", err,
"consecutive_errors", consecutiveErrors,
"backoff", backoff,
)
deadline := time.Now().Add(backoff)
g.cl.waitmeta(g.ctx, backoff, "waitmeta during group manage backoff")
after := time.NewTimer(time.Until(deadline))
select {
case <-g.ctx.Done():
after.Stop()
return true
case <-after.C:
}
return false
}
// Manages the group consumer's join / sync / heartbeat / fetch offset flow.
//
// Once a group is assigned, we fire a metadata request for all topics the
// assignment specified interest in. Only after we finally have some topic
// metadata do we join the group, and once joined, this management runs in a
// dedicated goroutine until the group is left.
func (g *groupConsumer) manage() {
defer close(g.manageDone)
g.cfg.logger.Log(LogLevelInfo, "beginning to manage the group lifecycle", "group", g.cfg.group)
if !g.cfg.autocommitDisable && g.cfg.autocommitInterval > 0 {
g.cfg.logger.Log(LogLevelInfo, "beginning autocommit loop", "group", g.cfg.group)
go g.loopCommit()
}
var consecutiveErrors int
joinWhy := "beginning to manage the group lifecycle"
for {
if joinWhy == "" {
joinWhy = "rejoining from normal rebalance"
}
err := g.joinAndSync(joinWhy)
if err == nil {
if joinWhy, err = g.setupAssignedAndHeartbeat(g.cfg.heartbeatInterval, g.heartbeatFn()); err != nil {
if errors.Is(err, kerr.RebalanceInProgress) {
err = nil
}
}
}
if err == nil {
consecutiveErrors = 0
continue
}
joinWhy = "rejoining after we previously errored and backed off"
consecutiveErrors++
ctxCanceled := g.manageFailWait(consecutiveErrors, err)
if ctxCanceled {
return
}
}
}
func (g *groupConsumer) leave(ctx context.Context) {
// If g.using is nonzero before this check, then a manage goroutine has
// started. If not, it will never start because we set dying.
g.mu.Lock()
wasDead := g.dying
g.dying = true
wasManaging := g.managing
is848 := g.is848
g.cancel()
g.mu.Unlock()
go func() {
if wasManaging {
// We want to wait for the manage goroutine to be done
// so that we call the user's on{Assign,RevokeLost}.
<-g.manageDone
}
if wasDead {
// If we already called leave(), then we just wait for
// the prior leave to finish and we avoid re-issuing a
// LeaveGroup request.
return
}
defer close(g.left)
// If we JUST started a group but do not yet have a
// member ID, there's nothing we can do.
memberID := g.memberGen.memberID()
if memberID == "" {
g.cfg.logger.Log(LogLevelInfo, "tried to leave group but we have no member ID yet, returning early", "group", g.cfg.group)
return
}
if is848 {
g.leave848(ctx)
return
}
if g.cfg.instanceID != nil {
return
}
g.cfg.logger.Log(LogLevelInfo, "leaving group",
"group", g.cfg.group,
"member_id", memberID,
)
// If we error when leaving, there is not much
// we can do. We may as well just return.
req := kmsg.NewPtrLeaveGroupRequest()
req.Group = g.cfg.group
req.MemberID = memberID
member := kmsg.NewLeaveGroupRequestMember()
member.MemberID = memberID
member.Reason = kmsg.StringPtr("client leaving group per normal operation")
req.Members = append(req.Members, member)
resp, err := req.RequestWith(ctx, g.cl)
if err != nil {
g.leaveErr = err
return
}
g.leaveErr = kerr.ErrorForCode(resp.ErrorCode)
}()
}
// returns the difference of g.nowAssigned and g.lastAssigned.
func (g *groupConsumer) diffAssigned() (added, lost map[string][]int32) {
nowAssigned := g.nowAssigned.clone()
if !g.cooperative.Load() {
return nowAssigned, nil
}
added = make(map[string][]int32, len(nowAssigned))
lost = make(map[string][]int32, len(nowAssigned))
// First, we diff lasts: any topic in last but not now is lost,
// otherwise, (1) new partitions are added, (2) common partitions are
// ignored, and (3) partitions no longer in now are lost.
lasts := make(map[int32]struct{}, 100)
for topic, lastPartitions := range g.lastAssigned {
nowPartitions, exists := nowAssigned[topic]
if !exists {
lost[topic] = lastPartitions
continue
}
for _, lastPartition := range lastPartitions {
lasts[lastPartition] = struct{}{}
}
// Anything now that does not exist in last is new,
// otherwise it is in common and we ignore it.
for _, nowPartition := range nowPartitions {
if _, exists := lasts[nowPartition]; !exists {
added[topic] = append(added[topic], nowPartition)
} else {
delete(lasts, nowPartition)
}
}
// Anything remanining in last does not exist now
// and is thus lost.
for last := range lasts {
lost[topic] = append(lost[topic], last)
delete(lasts, last) // reuse lasts
}
}
// Finally, any new topics in now assigned are strictly added.
for topic, nowPartitions := range nowAssigned {
if _, exists := g.lastAssigned[topic]; !exists {
added[topic] = nowPartitions
}
}
return added, lost
}
type revokeStage int8
const (
revokeLastSession = iota
revokeThisSession
)
// revoke calls onRevoked for partitions that this group member is losing and
// updates the uncommitted map after the revoke.
//
// For eager consumers, this simply revokes g.assigned. This will only be
// called at the end of a group session.
//
// For cooperative consumers, this either
//
// (1) if revoking lost partitions from a prior session (i.e., after sync),
// this revokes the passed in lost
// (2) if revoking at the end of a session, this revokes topics that the
// consumer is no longer interested in consuming
//
// Lastly, for cooperative consumers, this must selectively delete what was
// lost from the uncommitted map.
func (g *groupConsumer) revoke(stage revokeStage, lost map[string][]int32, leaving bool) {
g.c.waitAndAddRebalance()
defer g.c.unaddRebalance()
if !g.cooperative.Load() || leaving { // stage == revokeThisSession if not cooperative
// If we are an eager consumer, we stop fetching all of our
// current partitions as we will be revoking them.
g.c.mu.Lock()
if leaving {
g.c.assignPartitions(nil, assignInvalidateAll, nil, "revoking all assignments because we are leaving the group")
} else {
g.c.assignPartitions(nil, assignInvalidateAll, nil, "revoking all assignments because we are not cooperative")
}
g.c.mu.Unlock()
if !g.cooperative.Load() {
g.cfg.logger.Log(LogLevelInfo, "eager consumer revoking prior assigned partitions", "group", g.cfg.group, "revoking", g.nowAssigned.read())
} else {
g.cfg.logger.Log(LogLevelInfo, "cooperative consumer revoking prior assigned partitions because leaving group", "group", g.cfg.group, "revoking", g.nowAssigned.read())
}
if g.cfg.onRevoked != nil {
g.cfg.onRevoked(g.cl.ctx, g.cl, g.nowAssigned.read())
}
g.nowAssigned.store(nil)
g.lastAssigned = nil
// After nilling uncommitted here, nothing should recreate
// uncommitted until a future fetch after the group is
// rejoined. This _can_ be broken with a manual SetOffsets or
// with CommitOffsets{,Sync} but we explicitly document not
// to do that outside the context of a live group session.
g.mu.Lock()
g.uncommitted = nil
g.mu.Unlock()
return
}
switch stage {
case revokeLastSession:
// we use lost in this case; this is the case where we are
// rejoining after losing some partitions (cooperative or KIP-848)
case revokeThisSession:
// lost is nil for cooperative assigning. Instead, we determine
// lost by finding subscriptions we are no longer interested
// in. This would be from a user's PurgeConsumeTopics call.
//
// We just paused metadata, but purging triggers a rebalance
// which causes a new metadata request -- in short, this could
// be concurrent with a metadata findNewAssignments, so we
// lock.
g.nowAssigned.write(func(nowAssigned map[string][]int32) {
g.mu.Lock()
for topic, partitions := range nowAssigned {
if _, exists := g.using[topic]; !exists {
if lost == nil {
lost = make(map[string][]int32)
}
lost[topic] = partitions
delete(nowAssigned, topic)
}
}
g.mu.Unlock()
})
}
if len(lost) > 0 {
// We must now stop fetching anything we lost and invalidate
// any buffered fetches before falling into onRevoked.
//
// We want to invalidate buffered fetches since they may
// contain partitions that we lost, and we do not want a future
// poll to return those fetches.
lostOffsets := make(map[string]map[int32]Offset, len(lost))
for lostTopic, lostPartitions := range lost {
lostPartitionOffsets := make(map[int32]Offset, len(lostPartitions))
for _, lostPartition := range lostPartitions {
lostPartitionOffsets[lostPartition] = Offset{}
}
lostOffsets[lostTopic] = lostPartitionOffsets
}
// We must invalidate before revoking and before updating
// uncommitted, because we want any commits in onRevoke to be
// for the final polled offsets. We do not want to allow the
// logical race of allowing fetches for revoked partitions
// after a revoke but before an invalidation.
g.c.mu.Lock()
g.c.assignPartitions(lostOffsets, assignInvalidateMatching, g.tps, "revoking assignments from cooperative consuming")
g.c.mu.Unlock()
}
if len(lost) > 0 || stage == revokeThisSession {
if len(lost) == 0 {
g.cfg.logger.Log(LogLevelInfo, "consumer calling onRevoke at the end of a session; consumer did not change any client-side subscription", "group", g.cfg.group)
} else {
g.cfg.logger.Log(LogLevelInfo, "calling onRevoke at the end of a session", "group", g.cfg.group, "lost", lost, "stage", stage)
}
if g.cfg.onRevoked != nil {
g.cfg.onRevoked(g.cl.ctx, g.cl, lost)
}
}
if len(lost) == 0 { // if we lost nothing, do nothing
return
}
if stage != revokeThisSession { // cooperative consumers rejoin after they revoking what they lost
defer g.rejoin("after revoking what we lost from a rebalance")
}
// The block below deletes everything lost from our uncommitted map.
// All commits should be **completed** by the time this runs. An async
// commit can undo what we do below. The default revoke runs a sync
// commit.
g.mu.Lock()
defer g.mu.Unlock()
if g.uncommitted == nil {
return
}
for lostTopic, lostPartitions := range lost {
uncommittedPartitions := g.uncommitted[lostTopic]
if uncommittedPartitions == nil {
continue
}
for _, lostPartition := range lostPartitions {
delete(uncommittedPartitions, lostPartition)
}
if len(uncommittedPartitions) == 0 {
delete(g.uncommitted, lostTopic)
}
}
if len(g.uncommitted) == 0 {
g.uncommitted = nil
}
}
// assignRevokeSession aids in sequencing prerevoke/assign/revoke.
type assignRevokeSession struct {
prerevokeDone chan struct{}
assignDone chan struct{}
revokeDone chan struct{}
}
func newAssignRevokeSession() *assignRevokeSession {
return &assignRevokeSession{
prerevokeDone: make(chan struct{}),
assignDone: make(chan struct{}),
revokeDone: make(chan struct{}),
}
}
// For cooperative consumers, the first thing a cooperative consumer does is to
// diff its last assignment and its new assignment and revoke anything lost.
// We call this a "prerevoke".
func (s *assignRevokeSession) prerevoke(g *groupConsumer, lost map[string][]int32) <-chan struct{} {
go func() {
defer close(s.prerevokeDone)
if g.cooperative.Load() && len(lost) > 0 {
g.revoke(revokeLastSession, lost, false)
}
}()
return s.prerevokeDone
}
func (s *assignRevokeSession) assign(g *groupConsumer, newAssigned map[string][]int32) <-chan struct{} {
go func() {
defer close(s.assignDone)
<-s.prerevokeDone
if g.cfg.onAssigned != nil {
// We always call on assigned, even if nothing new is
// assigned. This allows consumers to know that
// assignment is done and do setup logic.
//
// If configured, we have to block polling.
g.c.waitAndAddRebalance()
defer g.c.unaddRebalance()
g.cfg.onAssigned(g.cl.ctx, g.cl, newAssigned)
}
}()
return s.assignDone
}
// At the end of a group session, before we leave the heartbeat loop, we call
// revoke. For non-cooperative consumers, this revokes everything in the
// current session, and before revoking, we invalidate all partitions. For the
// cooperative consumer, this does nothing but does notify the client that a
// revoke has begun / the group session is ending.
//
// This may not run before returning from the heartbeat loop: if we encounter a
// fatal error, we return before revoking so that we can instead call onLost in
// the manage loop.
func (s *assignRevokeSession) revoke(g *groupConsumer, leaving bool) <-chan struct{} {
go func() {
defer close(s.revokeDone)
<-s.assignDone
g.revoke(revokeThisSession, nil, leaving)
}()
return s.revokeDone
}
// This chunk of code "pre" revokes lost partitions for the cooperative
// consumer and then begins heartbeating while fetching offsets. This returns
// when heartbeating errors (or if fetch offsets errors).
//
// Before returning, this function ensures that
// - onAssigned is complete
// - which ensures that pre revoking is complete
// - fetching is complete
// - heartbeating is complete
func (g *groupConsumer) setupAssignedAndHeartbeat(initialHb time.Duration, hbfn func() (time.Duration, error)) (string, error) {
type hbquit struct {
rejoinWhy string
err error
}
hbErrCh := make(chan hbquit, 1)
fetchErrCh := make(chan error, 1)
s := newAssignRevokeSession()
added, lost := g.diffAssigned()
g.lastAssigned = g.nowAssigned.clone() // now that we are done with our last assignment, update it per the new assignment
g.cfg.logger.Log(LogLevelInfo, "new group session begun", "group", g.cfg.group, "added", mtps(added), "lost", mtps(lost))
s.prerevoke(g, lost) // for cooperative consumers
// Since we have joined the group, we immediately begin heartbeating.
// This will continue until the heartbeat errors, the group is killed,
// or the fetch offsets below errors.
ctx, cancel := context.WithCancel(g.ctx)
go func() {
defer cancel() // potentially kill offset fetching
g.cfg.logger.Log(LogLevelInfo, "beginning heartbeat loop", "group", g.cfg.group)
rejoinWhy, err := g.heartbeat(initialHb, fetchErrCh, s, hbfn)
hbErrCh <- hbquit{rejoinWhy, err}
}()
// We immediately begin fetching offsets. We want to wait until the
// fetch function returns, since it assumes within it that another
// assign cannot happen (it assigns partitions itself). Returning
// before the fetch completes would be not good.
//
// The difference between fetchDone and fetchErrCh is that fetchErrCh
// can kill heartbeating, or signal it to continue, while fetchDone
// is specifically used for this function's return.
fetchDone := make(chan struct{})
defer func() { <-fetchDone }()
// Before we fetch offsets, we wait for the user's onAssign callback to
// be done. This ensures a few things:
//
// * that we wait for prerevoking to be done, which updates the
// uncommitted field. Waiting for that ensures that a rejoin and poll
// does not have weird concurrent interaction.
//
// * that our onLost will not be concurrent with onAssign
//
// * that the user can start up any per-partition processors necessary
// before we begin consuming that partition.
//
// We especially need to wait here because heartbeating may not
// necessarily run onRevoke before returning (because of a fatal
// error).
s.assign(g, added)
// If cooperative consuming, we may have to resume fetches. See the
// comment on adjustCooperativeFetchOffsets.
//
// We do this AFTER the user's callback. If we add more partitions
// to `added` that are from a previously canceled fetch, we do NOT
// want to pass those fetch-resumed partitions to the user callback
// again. See #705.
if g.cooperative.Load() {
added = g.adjustCooperativeFetchOffsets(added, lost)
}
<-s.assignDone
if len(added) > 0 {
go func() {
defer close(fetchDone)
defer close(fetchErrCh)
fetchErrCh <- g.fetchOffsets(ctx, added)
}()
} else {
close(fetchDone)
close(fetchErrCh)
}
// Finally, we simply return whatever the heartbeat error is. This will
// be the fetch offset error if that function is what killed this.
done := <-hbErrCh
return done.rejoinWhy, done.err
}
func (g *groupConsumer) heartbeatFn() func() (time.Duration, error) {
return func() (time.Duration, error) {
req := kmsg.NewPtrHeartbeatRequest()
req.Group = g.cfg.group
memberID, generation := g.memberGen.load()
req.Generation = generation
req.MemberID = memberID
req.InstanceID = g.cfg.instanceID
var resp *kmsg.HeartbeatResponse
resp, err := req.RequestWith(g.ctx, g.cl)
if err == nil {
err = kerr.ErrorForCode(resp.ErrorCode)
}
return g.cfg.heartbeatInterval, err
}
}
// heartbeat issues heartbeat requests to Kafka for the duration of a group
// session.
//
// This function begins before fetching offsets to allow the consumer's
// onAssigned to be called before fetching. If the eventual offset fetch
// errors, we continue heartbeating until onRevoked finishes.
// If the error is not RebalanceInProgress, we return immediately.
//
// If the offset fetch is successful, then we basically sit in this function
// until a heartbeat errors or we, being the leader, decide to re-join.
func (g *groupConsumer) heartbeat(initialHb time.Duration, fetchErrCh <-chan error, s *assignRevokeSession, hbfn func() (time.Duration, error)) (string, error) {
g.mu.Lock()
is848 := g.is848
g.mu.Unlock()
timer := time.NewTimer(initialHb)
defer timer.Stop()
// We issue one heartbeat quickly if we are cooperative because
// cooperative consumers rejoin the group immediately, and we want to
// detect that in 500ms rather than 3s. We only want this is non-848
// mode.
var cooperativeFastCheck <-chan time.Time
if g.cooperative.Load() && !is848 {
cooperativeFastCheck = time.After(500 * time.Millisecond)
}
var revoked <-chan struct{}
var heartbeat, didRevoke bool
var rejoinWhy string
var lastErr error
ctxCh := g.ctx.Done()
for {
var err error
var force func(error)
heartbeat = false
select {
case <-cooperativeFastCheck:
heartbeat = true
case <-timer.C:
heartbeat = true
case force = <-g.heartbeatForceCh:
heartbeat = true
case rejoinWhy = <-g.rejoinCh:
// If a metadata update changes our subscription,
// we just pretend we are rebalancing.
g.cfg.logger.Log(LogLevelInfo, "forced rejoin quitting heartbeat loop", "why", rejoinWhy)
err = kerr.RebalanceInProgress
case err = <-fetchErrCh:
fetchErrCh = nil
case <-revoked:
revoked = nil
didRevoke = true
case <-ctxCh:
// Even if the group is left, we need to wait for our
// revoke to finish before returning, otherwise the
// manage goroutine will race with us setting
// nowAssigned.
ctxCh = nil
err = context.Canceled
}
if heartbeat {
g.cfg.logger.Log(LogLevelDebug, "heartbeating", "group", g.cfg.group)
var reset time.Duration
reset, err = hbfn()
timer.Reset(reset)
g.cfg.logger.Log(LogLevelDebug, "heartbeat complete", "group", g.cfg.group, "err", err)
if force != nil {
force(err)
}
}
// The first error either triggers a clean revoke or it returns
// immediately. If we triggered the revoke, we wait for it to
// complete regardless of any future error.
if didRevoke {
return rejoinWhy, lastErr
}
if err == nil {
continue
}
if lastErr == nil {
if is848 && errors.Is(err, kerr.RebalanceInProgress) {
g.cfg.logger.Log(LogLevelInfo, "heartbeat saw a change in group status; partitions were added or lost", "group", g.cfg.group)
} else {
g.cfg.logger.Log(LogLevelInfo, "heartbeat errored", "group", g.cfg.group, "err", err)
}
} else {
g.cfg.logger.Log(LogLevelInfo, "heartbeat errored again while waiting for user revoke to finish", "group", g.cfg.group, "err", err)
}
// Since we errored, we must revoke.
if !didRevoke && revoked == nil {
// If our error is not from rebalancing, then we
// encountered IllegalGeneration or UnknownMemberID or
// our context closed all of which are unexpected and
// unrecoverable.
//
// We return early rather than revoking and updating
// metadata; the groupConsumer's manage function will
// call onLost with all partitions.
//
// setupAssignedAndHeartbeat still waits for onAssigned
// to be done so that we avoid calling onLost
// concurrently.
if !errors.Is(err, kerr.RebalanceInProgress) && revoked == nil {
return "", err
}
// Now we call the user provided revoke callback, even
// if cooperative: if cooperative, this only revokes
// partitions we no longer want to consume.
//
// If the err is context.Canceled, the group is being
// left and we revoke everything.
revoked = s.revoke(g, errors.Is(err, context.Canceled))
}
// We always save the latest error; generally this should be
// REBALANCE_IN_PROGRESS, but if the revoke takes too long,
// Kafka may boot us and we will get a different error.
lastErr = err
}
}
// ForceRebalance quits a group member's heartbeat loop so that the member
// rejoins with a JoinGroupRequest.
//
// This function is only useful if you either (a) know that the group member is
// a leader, and want to force a rebalance for any particular reason, or (b)
// are using a custom group balancer, and have changed the metadata that will
// be returned from its JoinGroupMetadata method. This function has no other
// use; see KIP-568 for more details around this function's motivation.
//
// If neither of the cases above are true (this member is not a leader, and the
// join group metadata has not changed), then Kafka will not actually trigger a
// rebalance and will instead reply to the member with its current assignment.
func (cl *Client) ForceRebalance() {
if g := cl.consumer.g; g != nil {
g.rejoin("from ForceRebalance")
}
}
// rejoin is called after a cooperative member revokes what it lost at the
// beginning of a session, or if we are leader and detect new partitions to
// consume.
func (g *groupConsumer) rejoin(why string) {
select {
case g.rejoinCh <- why:
default:
}
}
// Joins and then syncs, issuing the two slow requests in goroutines to allow
// for group cancelation to return early.
func (g *groupConsumer) joinAndSync(joinWhy string) error {
g.noCommitDuringJoinAndSync.Lock()
g.cfg.logger.Log(LogLevelDebug, "blocking commits from join&sync")
defer g.noCommitDuringJoinAndSync.Unlock()
defer g.cfg.logger.Log(LogLevelDebug, "unblocking commits from join&sync")
g.cfg.logger.Log(LogLevelInfo, "joining group", "group", g.cfg.group)
g.leader.Store(false)
g.getAndResetExternalRejoin()
defer func() {
// If we are not leader, we clear any tracking of external
// topics from when we were previously leader, since tracking
// these is just a waste.
if !g.leader.Load() {
g.resetExternal()
}
}()
start:
select {
case <-g.rejoinCh: // drain to avoid unnecessary rejoins
default:
}
joinReq := kmsg.NewPtrJoinGroupRequest()
joinReq.Group = g.cfg.group
joinReq.SessionTimeoutMillis = int32(g.cfg.sessionTimeout.Milliseconds())
joinReq.RebalanceTimeoutMillis = int32(g.cfg.rebalanceTimeout.Milliseconds())
joinReq.ProtocolType = g.cfg.protocol
joinReq.MemberID = g.memberGen.memberID()
joinReq.InstanceID = g.cfg.instanceID
joinReq.Protocols = g.joinGroupProtocols()
if joinWhy != "" {
joinReq.Reason = kmsg.StringPtr(joinWhy)
}
var (
joinResp *kmsg.JoinGroupResponse
err error
joined = make(chan struct{})
)
// NOTE: For this function, we have to use the client context, not the
// group context. We want to allow people to issue one final commit in
// OnPartitionsRevoked before leaving a group, so we need to block
// commits during join&sync. If we used the group context, we would be
// cancled immediately when leaving while a join or sync is inflight,
// and then our final commit will receive either REBALANCE_IN_PROGRESS
// or ILLEGAL_GENERATION.
go func() {
defer close(joined)
joinResp, err = joinReq.RequestWith(g.cl.ctx, g.cl)
}()
select {
case <-joined:
case <-g.cl.ctx.Done():
return g.cl.ctx.Err() // client closed
}
if err != nil {
return err
}
restart, protocol, plan, err := g.handleJoinResp(joinResp)
if restart {
goto start
}
if err != nil {
g.cfg.logger.Log(LogLevelWarn, "join group failed", "group", g.cfg.group, "err", err)
return err
}
syncReq := kmsg.NewPtrSyncGroupRequest()
syncReq.Group = g.cfg.group
memberID, generation := g.memberGen.load()
syncReq.Generation = generation
syncReq.MemberID = memberID
syncReq.InstanceID = g.cfg.instanceID
syncReq.ProtocolType = &g.cfg.protocol
syncReq.Protocol = &protocol
if !joinResp.SkipAssignment {
syncReq.GroupAssignment = plan // nil unless we are the leader
}
var (
syncResp *kmsg.SyncGroupResponse
synced = make(chan struct{})
)
g.cfg.logger.Log(LogLevelInfo, "syncing", "group", g.cfg.group, "protocol_type", g.cfg.protocol, "protocol", protocol)
go func() {
defer close(synced)
syncResp, err = syncReq.RequestWith(g.cl.ctx, g.cl)
}()
select {
case <-synced:
case <-g.cl.ctx.Done():
return g.cl.ctx.Err()
}
if err != nil {
return err
}
if err = g.handleSyncResp(protocol, syncResp); err != nil {
if errors.Is(err, kerr.RebalanceInProgress) {
g.cfg.logger.Log(LogLevelInfo, "sync failed with RebalanceInProgress, rejoining", "group", g.cfg.group)
goto start
}
g.cfg.logger.Log(LogLevelWarn, "sync group failed", "group", g.cfg.group, "err", err)
return err
}
// KIP-814 fixes one limitation with KIP-345, but has another
// fundamental limitation. When an instance ID leader restarts, its
// first join always gets its old assignment *even if* the member's
// topic interests have changed. The broker tells us to skip doing
// assignment ourselves, but we ignore that for our well known
// balancers. Instead, we balance (but avoid sending it while syncing,
// as we are supposed to), and if our sync assignment differs from our
// own calculated assignment, We know we have a stale broker assignment
// and must trigger a rebalance.
if plan != nil && joinResp.SkipAssignment {
for _, assign := range plan {
if assign.MemberID == memberID {
if !bytes.Equal(assign.MemberAssignment, syncResp.MemberAssignment) {
g.rejoin("instance group leader restarted and was reassigned old plan, our topic interests changed and we must rejoin to force a rebalance")
}
break
}
}
}
return nil
}
func (g *groupConsumer) handleJoinResp(resp *kmsg.JoinGroupResponse) (restart bool, protocol string, plan []kmsg.SyncGroupRequestGroupAssignment, err error) {
if err = kerr.ErrorForCode(resp.ErrorCode); err != nil {
switch err {
case kerr.MemberIDRequired:
g.memberGen.storeMember(resp.MemberID) // KIP-394
g.cfg.logger.Log(LogLevelInfo, "join returned MemberIDRequired, rejoining with response's MemberID", "group", g.cfg.group, "member_id", resp.MemberID)
return true, "", nil, nil
case kerr.UnknownMemberID:
g.memberGen.storeMember("")
g.cfg.logger.Log(LogLevelInfo, "join returned UnknownMemberID, rejoining without a member id", "group", g.cfg.group)
return true, "", nil, nil
}
return restart, protocol, plan, err // Request retries as necessary, so this must be a failure
}
g.memberGen.store(resp.MemberID, resp.Generation)
if resp.Protocol != nil {
protocol = *resp.Protocol
}
for _, balancer := range g.cfg.balancers {
if protocol == balancer.ProtocolName() {
cooperative := balancer.IsCooperative()
if !cooperative && g.cooperative.Load() {
g.cfg.logger.Log(LogLevelWarn, "downgrading from cooperative group to eager group, this is not supported per KIP-429!")
}
g.cooperative.Store(cooperative)
break
}
}
// KIP-345 has a fundamental limitation that KIP-814 also does not
// solve.
//
// When using instance IDs, if a leader restarts, its first join
// receives its old assignment no matter what. KIP-345 resulted in
// leaderless consumer groups, KIP-814 fixes this by notifying the
// restarted leader that it is still leader but that it should not
// balance.
//
// If the join response is <= v8, we hackily work around the leaderless
// situation by checking if the LeaderID is prefixed with our
// InstanceID. This is how Kafka and Redpanda are both implemented. At
// worst, if we mis-predict the leader, then we may accidentally try to
// cause a rebalance later and it will do nothing. That's fine. At
// least we can cause rebalances now, rather than having a leaderless,
// not-ever-rebalancing client.
//
// KIP-814 does not solve our problem fully: if we restart and rejoin,
// we always get our old assignment even if we changed what topics we
// were interested in. Because we have our old assignment, we think
// that the plan is fine *even with* our new interests, and we wait for
// some external rebalance trigger. We work around this limitation
// above (see "KIP-814") only for well known balancers; we cannot work
// around this limitation for not well known balancers because they may
// do so weird things we cannot control nor reason about.
leader := resp.LeaderID == resp.MemberID
leaderNoPlan := !leader && resp.Version <= 8 && g.cfg.instanceID != nil && strings.HasPrefix(resp.LeaderID, *g.cfg.instanceID+"-")
if leader {
g.leader.Store(true)
g.cfg.logger.Log(LogLevelInfo, "joined, balancing group",
"group", g.cfg.group,
"member_id", resp.MemberID,
"instance_id", strptr{g.cfg.instanceID},
"generation", resp.Generation,
"balance_protocol", protocol,
"leader", true,
)
plan, err = g.balanceGroup(protocol, resp.Members, resp.SkipAssignment)
} else if leaderNoPlan {
g.leader.Store(true)
g.cfg.logger.Log(LogLevelInfo, "joined as leader but unable to balance group due to KIP-345 limitations",
"group", g.cfg.group,
"member_id", resp.MemberID,
"instance_id", strptr{g.cfg.instanceID},
"generation", resp.Generation,
"balance_protocol", protocol,
"leader", true,
)
} else {
g.cfg.logger.Log(LogLevelInfo, "joined",
"group", g.cfg.group,
"member_id", resp.MemberID,
"instance_id", strptr{g.cfg.instanceID},
"generation", resp.Generation,
"leader", false,
)
}
return restart, protocol, plan, err
}
type strptr struct {
s *string
}
func (s strptr) String() string {
if s.s == nil {
return "<nil>"
}
return *s.s
}
// If other group members consume topics we are not interested in, we track the
// entire group's topics in this groupExternal type. On metadata update, we see
// if any partitions for any of these topics have changed, and if so, we as
// leader rejoin the group.
//
// Our external topics are cleared whenever we join and are not leader. We keep
// our previous external topics if we are leader: on the first balance as
// leader, we request metadata for all topics, then on followup balances, we
// already have that metadata and do not need to reload it when balancing.
//
// Whenever metadata updates, we detect if a rejoin is needed and always reset
// the rejoin status.
type groupExternal struct {
tps atomic.Value // map[string]int32
rejoin atomic.Bool
}
func (g *groupConsumer) loadExternal() *groupExternal {
e := g.external.Load()
if e != nil {
return e.(*groupExternal)
}
return nil
}
// We reset our external topics whenever join&sync loop errors, or when we join
// and are not leader.
func (g *groupConsumer) resetExternal() {
g.external.Store((*groupExternal)(nil))
}
// If this is our first join as leader, or if a new member joined with new
// topics we were not tracking, we re-initialize external with the all-topics
// metadata refresh.
func (g *groupConsumer) initExternal(current map[string]int32) {
var e groupExternal
e.tps.Store(dupmsi32(current))
g.external.Store(&e)
}
// Reset whenever we join, & potentially used to rejoin when finding new
// assignments (i.e., end of metadata).
func (g *groupConsumer) getAndResetExternalRejoin() bool {
e := g.loadExternal()
if e == nil {
return false
}
defer e.rejoin.Store(false)
return e.rejoin.Load()
}
// Runs fn over a load, not copy, of our map.
func (g *groupExternal) fn(fn func(map[string]int32)) {
if g == nil {
return
}
v := g.tps.Load()
if v == nil {
return
}
tps := v.(map[string]int32)
fn(tps)
}
// Runs fn over a clone of our external map and updates the map.
func (g *groupExternal) cloned(fn func(map[string]int32)) {
g.fn(func(tps map[string]int32) {
dup := dupmsi32(tps)
fn(dup)
g.tps.Store(dup)
})
}
func (g *groupExternal) eachTopic(fn func(string)) {
g.fn(func(tps map[string]int32) {
for t := range tps {
fn(t)
}
})
}
func (g *groupExternal) updateLatest(meta map[string]*metadataTopic) {
g.cloned(func(tps map[string]int32) {
var rejoin bool
for t, ps := range tps {
latest, exists := meta[t]
if !exists || latest.loadErr != nil {
continue
}
if psLatest := int32(len(latest.partitions)); psLatest != ps {
rejoin = true
tps[t] = psLatest
}
}
if rejoin {
g.rejoin.Store(true)
}
})
}
func (g *groupConsumer) handleSyncResp(protocol string, resp *kmsg.SyncGroupResponse) error {
if err := kerr.ErrorForCode(resp.ErrorCode); err != nil {
return err
}
b, err := g.findBalancer("sync assignment", protocol)
if err != nil {
return err
}
assigned, err := b.ParseSyncAssignment(resp.MemberAssignment)
if err != nil {
g.cfg.logger.Log(LogLevelError, "sync assignment parse failed", "group", g.cfg.group, "err", err)
return err
}
for _, v := range assigned {
slices.Sort(v)
}
g.cfg.logger.Log(LogLevelInfo, "synced", "group", g.cfg.group, "assigned", mtps(assigned))
// Past this point, we will fall into the setupAssigned prerevoke code,
// meaning for cooperative, we will revoke what we need to.
g.nowAssigned.store(assigned)
return nil
}
func (g *groupConsumer) joinGroupProtocols() []kmsg.JoinGroupRequestProtocol {
g.mu.Lock()
topics := make([]string, 0, len(g.using))
for topic := range g.using {
topics = append(topics, topic)
}
lastDup := make(map[string][]int32, len(g.lastAssigned))
for t, ps := range g.lastAssigned {
lastDup[t] = slices.Clone(ps) // deep copy to allow modifications
}
g.mu.Unlock()
sort.Strings(topics) // we guarantee to JoinGroupMetadata that the input strings are sorted
for _, partitions := range lastDup {
slices.Sort(partitions) // same for partitions
}
gen := g.memberGen.generation()
var protos []kmsg.JoinGroupRequestProtocol
for _, balancer := range g.cfg.balancers {
proto := kmsg.NewJoinGroupRequestProtocol()
proto.Name = balancer.ProtocolName()
proto.Metadata = balancer.JoinGroupMetadata(topics, lastDup, gen)
protos = append(protos, proto)
}
return protos
}
// If we are cooperatively consuming, we have a potential problem: if fetch
// offsets is canceled due to an immediate rebalance, when we resume, we will
// not re-fetch offsets for partitions we were previously assigned and are
// still assigned. We will only fetch offsets for new assignments.
//
// To work around that issue, we track everything we are fetching in g.fetching
// and only clear g.fetching if fetchOffsets returns with no error.
//
// Now, if fetching returns early due to an error, when we rejoin and re-fetch,
// we will resume fetching what we were previously:
//
// - first we remove what was lost
// - then we add anything new
// - then we translate our total set into the "added" list to be fetched on return
//
// Any time a group is completely lost, the manage loop clears fetching. When
// cooperative consuming, a hard error is basically losing the entire state and
// rejoining from scratch.
func (g *groupConsumer) adjustCooperativeFetchOffsets(added, lost map[string][]int32) map[string][]int32 {
if g.fetching != nil {
// We were fetching previously: remove anything lost.
for topic, partitions := range lost {
ft := g.fetching[topic]
if ft == nil {
continue // we were not fetching this topic
}
for _, partition := range partitions {
delete(ft, partition)
}
if len(ft) == 0 {
delete(g.fetching, topic)
}
}
} else {
// We were not fetching previously: start a new map for what we
// are adding.
g.fetching = make(map[string]map[int32]struct{})
}
// Merge everything we are newly fetching to our fetching map.
for topic, partitions := range added {
ft := g.fetching[topic]
if ft == nil {
ft = make(map[int32]struct{}, len(partitions))
g.fetching[topic] = ft
}
for _, partition := range partitions {
ft[partition] = struct{}{}
}
}
// Now translate our full set (previously fetching ++ newly fetching --
// lost) into a new "added" map to be fetched.
added = make(map[string][]int32, len(g.fetching))
for topic, partitions := range g.fetching {
ps := make([]int32, 0, len(partitions))
for partition := range partitions {
ps = append(ps, partition)
}
added[topic] = ps
}
return added
}
// fetchOffsets is issued once we join a group to see what the prior commits
// were for the partitions we were assigned.
func (g *groupConsumer) fetchOffsets(ctx context.Context, added map[string][]int32) (rerr error) { // we must use "rerr"! see introducing commit
// If we fetch successfully, we can clear the cross-group-cycle
// fetching tracking.
defer func() {
if rerr == nil {
g.fetching = nil
}
}()
// Our client maps the v0 to v7 format to v8+ when sharding this
// request, if we are only requesting one group, as well as maps the
// response back, so we do not need to worry about v8+ here.
start:
member, gen := g.memberGen.load()
req := kmsg.NewPtrOffsetFetchRequest()
req.RequireStable = g.cfg.requireStable
reqg := kmsg.NewOffsetFetchRequestGroup()
reqg.Group = g.cfg.group
if member != "" {
reqg.MemberID = &member
reqg.MemberEpoch = gen
}
for topic, partitions := range added {
reqTopic := kmsg.NewOffsetFetchRequestGroupTopic()
reqTopic.Topic = topic
reqTopic.Partitions = partitions
reqg.Topics = append(reqg.Topics, reqTopic)
}
req.Groups = append(req.Groups, reqg)
var resp *kmsg.OffsetFetchResponse
var err error
fetchDone := make(chan struct{})
go func() {
defer close(fetchDone)
resp, err = req.RequestWith(ctx, g.cl)
}()
select {
case <-fetchDone:
case <-ctx.Done():
g.cfg.logger.Log(LogLevelInfo, "fetch offsets failed due to context cancelation", "group", g.cfg.group)
return ctx.Err()
}
if err != nil {
g.cfg.logger.Log(LogLevelError, "fetch offsets failed with non-retryable error", "group", g.cfg.group, "err", err)
return err
}
// Even if a leader epoch is returned, if brokers do not support
// OffsetForLeaderEpoch for some reason (odd set of supported reqs), we
// cannot use the returned leader epoch.
kip320 := g.cl.supportsOffsetForLeaderEpoch()
offsets := make(map[string]map[int32]Offset)
for _, rTopic := range resp.Topics {
topicOffsets := make(map[int32]Offset)
offsets[rTopic.Topic] = topicOffsets
for _, rPartition := range rTopic.Partitions {
if err = kerr.ErrorForCode(rPartition.ErrorCode); err != nil {
// KIP-447: Unstable offset commit means there is a
// pending transaction that should be committing soon.
// We sleep for 1s and retry fetching offsets.
if errors.Is(err, kerr.UnstableOffsetCommit) {
g.cfg.logger.Log(LogLevelInfo, "fetch offsets failed with UnstableOffsetCommit, waiting 1s and retrying",
"group", g.cfg.group,
"topic", rTopic.Topic,
"partition", rPartition.Partition,
)
select {
case <-ctx.Done():
case <-time.After(time.Second):
goto start
}
}
g.cfg.logger.Log(LogLevelError, "fetch offsets failed",
"group", g.cfg.group,
"topic", rTopic.Topic,
"partition", rPartition.Partition,
"err", err,
)
return err
}
offset := Offset{
at: rPartition.Offset,
epoch: -1,
}
if resp.Version >= 5 && kip320 { // KIP-320
offset.epoch = rPartition.LeaderEpoch
}
if rPartition.Offset == -1 {
offset = g.cfg.startOffset
}
topicOffsets[rPartition.Partition] = offset
}
}
groupTopics := g.tps.load()
for fetchedTopic := range offsets {
if !groupTopics.hasTopic(fetchedTopic) {
delete(offsets, fetchedTopic)
g.cfg.logger.Log(LogLevelWarn, "member was assigned topic that we did not ask for in ConsumeTopics! skipping assigning this topic!", "group", g.cfg.group, "topic", fetchedTopic)
}
}
if g.cfg.onFetched != nil {
g.onFetchedMu.Lock()
err = g.cfg.onFetched(ctx, g.cl, resp)
g.onFetchedMu.Unlock()
if err != nil {
return err
}
}
if g.cfg.adjustOffsetsBeforeAssign != nil {
g.onFetchedMu.Lock()
offsets, err = g.cfg.adjustOffsetsBeforeAssign(ctx, offsets)
g.onFetchedMu.Unlock()
if err != nil {
return err
}
}
// Lock for assign and then updating uncommitted.
g.c.mu.Lock()
defer g.c.mu.Unlock()
g.mu.Lock()
defer g.mu.Unlock()
// Eager: we already invalidated everything; nothing to re-invalidate.
// Cooperative: assign without invalidating what we are consuming.
g.c.assignPartitions(offsets, assignWithoutInvalidating, g.tps, fmt.Sprintf("newly fetched offsets for group %s", g.cfg.group))
// We need to update the uncommitted map so that SetOffsets(Committed)
// does not rewind before the committed offsets we just fetched.
if g.uncommitted == nil {
g.uncommitted = make(uncommitted, 10)
}
for topic, partitions := range offsets {
topicUncommitted := g.uncommitted[topic]
if topicUncommitted == nil {
topicUncommitted = make(map[int32]uncommit, 20)
g.uncommitted[topic] = topicUncommitted
}
for partition, offset := range partitions {
if offset.at < 0 {
continue // not yet committed
}
committed := EpochOffset{
Epoch: offset.epoch,
Offset: offset.at,
}
topicUncommitted[partition] = uncommit{
dirty: committed,
head: committed,
committed: committed,
}
}
}
return nil
}
// findNewAssignments updates topics the group wants to use and other metadata.
// We only grab the group mu at the end if we need to.
//
// This joins the group if
// - the group has never been joined
// - new topics are found for consuming (changing this consumer's join metadata)
//
// Additionally, if the member is the leader, this rejoins the group if the
// leader notices new partitions in an existing topic.
//
// This does not rejoin if the leader notices a partition is lost, which is
// finicky.
func (g *groupConsumer) findNewAssignments() {
topics := g.tps.load()
type change struct {
isNew bool
delta int
}
var numNewTopics int
toChange := make(map[string]change, len(topics))
for topic, topicPartitions := range topics {
parts := topicPartitions.load()
numPartitions := len(parts.partitions)
// If we are already using this topic, add that it changed if
// there are more partitions than we were using prior.
if used, exists := g.using[topic]; exists {
if added := numPartitions - used; added > 0 {
toChange[topic] = change{delta: added}
}
continue
}
// We are iterating over g.tps, which is initialized in the
// group.init from the config's topics, but can also be added
// to in AddConsumeTopics. By default, we use the topic. If
// this is regex based, the config's topics are regular
// expressions that we need to evaluate against (and we do not
// support adding new regex).
useTopic := true
if g.cfg.regex {
useTopic = g.reSeen[topic]
}
// We only track using the topic if there are partitions for
// it; if there are none, then the topic was set by _us_ as "we
// want to load the metadata", but the topic was not returned
// in the metadata (or it was returned with an error).
if useTopic && numPartitions > 0 {
if g.cfg.regex && parts.isInternal {
continue
}
toChange[topic] = change{isNew: true, delta: numPartitions}
numNewTopics++
}
}
externalRejoin := g.leader.Load() && g.getAndResetExternalRejoin()
if len(toChange) == 0 && !externalRejoin {
return
}
g.mu.Lock()
defer g.mu.Unlock()
if g.dying {
return
}
for topic, change := range toChange {
g.using[topic] += change.delta
}
if !g.managing {
g.managing = true
if g.should848() {
g.is848 = true
go g.manage848()
return
}
go g.manage()
return
}
if numNewTopics > 0 {
g.rejoin("rejoining because there are more topics to consume, our interests have changed")
} else if g.leader.Load() {
if len(toChange) > 0 {
g.rejoin("rejoining because we are the leader and noticed some topics have new partitions")
} else if externalRejoin {
g.rejoin("leader detected that partitions on topics another member is consuming have changed, rejoining to trigger rebalance")
}
}
}
// uncommit tracks the latest offset polled (+1) and the latest commit.
// The reason head is just past the latest offset is because we want
// to commit TO an offset, not BEFORE an offset.
type uncommit struct {
dirty EpochOffset // if autocommitting, what will move to head on next Poll
head EpochOffset // ready to commit
committed EpochOffset // what is committed
}
// EpochOffset combines a record offset with the leader epoch the broker
// was at when the record was written.
type EpochOffset struct {
// Epoch is the leader epoch of the record being committed. Truncation
// detection relies on the epoch of the CURRENT record. For truncation
// detection, the client asks "what is the end of this epoch?",
// which returns one after the end offset (see the next field, and
// check the docs on kmsg.OffsetForLeaderEpochRequest).
Epoch int32
// Offset is the offset of a record. If committing, this should be one
// AFTER a record's offset. Clients start consuming at the offset that
// is committed.
Offset int64
}
// Less returns whether the this EpochOffset is less than another. This is less
// than the other if this one's epoch is less, or the epoch's are equal and
// this one's offset is less.
func (e EpochOffset) Less(o EpochOffset) bool {
ee, oe := max(e.Epoch, -1), max(o.Epoch, -1)
return ee < oe || ee == oe && e.Offset < o.Offset
}
type uncommitted map[string]map[int32]uncommit
// updateUncommitted sets the latest uncommitted offset.
func (g *groupConsumer) updateUncommitted(fetches Fetches) {
var b bytes.Buffer
debug := g.cfg.logger.Level() >= LogLevelDebug
// We set the head offset if autocommitting is disabled (because we
// only use head / committed in that case), or if we are greedily
// autocommitting (so that the latest head is available to autocommit).
setHead := g.cfg.autocommitDisable || g.cfg.autocommitGreedy
g.mu.Lock()
defer g.mu.Unlock()
for _, fetch := range fetches {
for _, topic := range fetch.Topics {
if debug {
fmt.Fprintf(&b, "%s[", topic.Topic)
}
var topicOffsets map[int32]uncommit
for _, partition := range topic.Partitions {
if len(partition.Records) == 0 {
continue
}
final := partition.Records[len(partition.Records)-1]
if topicOffsets == nil {
if g.uncommitted == nil {
g.uncommitted = make(uncommitted, 10)
}
topicOffsets = g.uncommitted[topic.Topic]
if topicOffsets == nil {
topicOffsets = make(map[int32]uncommit, 20)
g.uncommitted[topic.Topic] = topicOffsets
}
}
// Our new head points just past the final consumed offset,
// that is, if we rejoin, this is the offset to begin at.
set := EpochOffset{
final.LeaderEpoch, // -1 if old message / unknown
final.Offset + 1,
}
prior, ok := topicOffsets[partition.Partition]
if !ok {
uninit := EpochOffset{-1, 0}
uncommit := uncommit{uninit, uninit, uninit}
prior, topicOffsets[partition.Partition] = uncommit, uncommit
}
if debug {
if setHead {
fmt.Fprintf(&b, "%d{%d=>%d r%d e%d}, ", partition.Partition, prior.head.Offset, set.Offset, len(partition.Records), set.Epoch)
} else {
fmt.Fprintf(&b, "%d{%d=>%d=>%d r%d e%d}, ", partition.Partition, prior.head.Offset, prior.dirty.Offset, set.Offset, len(partition.Records), set.Epoch)
}
}
prior.dirty = set
if setHead {
prior.head = set
}
topicOffsets[partition.Partition] = prior
}
if debug {
if bytes.HasSuffix(b.Bytes(), []byte(", ")) {
b.Truncate(b.Len() - 2)
}
b.WriteString("], ")
}
}
}
if debug {
update := b.String()
update = strings.TrimSuffix(update, ", ") // trim trailing comma and space after final topic
g.cfg.logger.Log(LogLevelDebug, "updated uncommitted", "group", g.cfg.group, "to", update)
}
}
// Called at the start of PollXyz only if autocommitting is enabled and we are
// not committing greedily, this ensures that when we enter poll, everything
// previously consumed is a candidate for autocommitting.
func (g *groupConsumer) undirtyUncommitted() {
if g == nil {
return
}
// Disabling autocommit means we do not use the dirty offset: we always
// update head, and then manual commits use that.
if g.cfg.autocommitDisable {
return
}
// Greedy autocommitting does not use dirty offsets, because we always
// just set head to the latest.
if g.cfg.autocommitGreedy {
return
}
// If we are autocommitting marked records only, then we do not
// automatically un-dirty our offsets.
if g.cfg.autocommitMarks {
return
}
g.mu.Lock()
defer g.mu.Unlock()
for _, partitions := range g.uncommitted {
for partition, uncommit := range partitions {
if uncommit.dirty != uncommit.head {
uncommit.head = uncommit.dirty
partitions[partition] = uncommit
}
}
}
}
// updateCommitted updates the group's uncommitted map. This function triply
// verifies that the resp matches the req as it should and that the req does
// not somehow contain more than what is in our uncommitted map.
func (g *groupConsumer) updateCommitted(
req *kmsg.OffsetCommitRequest,
resp *kmsg.OffsetCommitResponse,
) {
g.mu.Lock()
defer g.mu.Unlock()
if req.Generation != g.memberGen.generation() {
return
}
if g.uncommitted == nil {
g.cfg.logger.Log(LogLevelWarn, "received an OffsetCommitResponse after our group session has ended, unable to handle this (were we kicked from the group?)")
return
}
if len(req.Topics) != len(resp.Topics) { // bad kafka
g.cfg.logger.Log(LogLevelError, fmt.Sprintf("broker replied to our OffsetCommitRequest incorrectly! Num topics in request: %d, in reply: %d, we cannot handle this!", len(req.Topics), len(resp.Topics)), "group", g.cfg.group)
return
}
sort.Slice(req.Topics, func(i, j int) bool {
return req.Topics[i].Topic < req.Topics[j].Topic
})
sort.Slice(resp.Topics, func(i, j int) bool {
return resp.Topics[i].Topic < resp.Topics[j].Topic
})
var b bytes.Buffer
debug := g.cfg.logger.Level() >= LogLevelDebug
for i := range resp.Topics {
reqTopic := &req.Topics[i]
respTopic := &resp.Topics[i]
topic, exists := g.uncommitted[respTopic.Topic]
if !exists {
continue // just in case; concurrent rebalance lost the topic while commit was in flight
}
if reqTopic.Topic != respTopic.Topic || // bad kafka
len(reqTopic.Partitions) != len(respTopic.Partitions) { // same
g.cfg.logger.Log(LogLevelError, fmt.Sprintf("broker replied to our OffsetCommitRequest incorrectly! Topic at request index %d: %s, reply at index: %s; num partitions on request topic: %d, in reply: %d, we cannot handle this!", i, reqTopic.Topic, respTopic.Topic, len(reqTopic.Partitions), len(respTopic.Partitions)), "group", g.cfg.group)
continue
}
sort.Slice(reqTopic.Partitions, func(i, j int) bool {
return reqTopic.Partitions[i].Partition < reqTopic.Partitions[j].Partition
})
sort.Slice(respTopic.Partitions, func(i, j int) bool {
return respTopic.Partitions[i].Partition < respTopic.Partitions[j].Partition
})
if debug {
fmt.Fprintf(&b, "%s[", respTopic.Topic)
}
for i := range respTopic.Partitions {
reqPart := &reqTopic.Partitions[i]
respPart := &respTopic.Partitions[i]
uncommit, exists := topic[respPart.Partition]
if !exists { // just in case
continue
}
if reqPart.Partition != respPart.Partition { // bad kafka
g.cfg.logger.Log(LogLevelError, fmt.Sprintf("broker replied to our OffsetCommitRequest incorrectly! Topic %s partition %d != resp partition %d", reqTopic.Topic, reqPart.Partition, respPart.Partition), "group", g.cfg.group)
continue
}
if respPart.ErrorCode != 0 {
g.cfg.logger.Log(LogLevelWarn, "unable to commit offset for topic partition",
"group", g.cfg.group,
"topic", reqTopic.Topic,
"partition", reqPart.Partition,
"commit_from", uncommit.committed.Offset,
"commit_to", reqPart.Offset,
"commit_epoch", reqPart.LeaderEpoch,
"error_code", respPart.ErrorCode,
)
continue
}
if debug {
fmt.Fprintf(&b, "%d{%d=>%d}, ", reqPart.Partition, uncommit.committed.Offset, reqPart.Offset)
}
set := EpochOffset{
reqPart.LeaderEpoch,
reqPart.Offset,
}
uncommit.committed = set
// head is set in four places:
// (1) if manually committing or greedily autocommitting,
// then head is bumped on poll
// (2) if autocommitting normally, then head is bumped
// to the prior poll on poll
// (3) if using marks, head is bumped on mark
// (4) here, and we can be here on autocommit or on
// manual commit (usually manual in an onRevoke)
//
// head is usually at or past the commit: usually, head
// is used to build the commit itself. However, in case 4
// when the user manually commits in onRevoke, the user
// is likely committing with UncommittedOffsets, i.e.,
// the dirty offsets that are past the current head.
// We want to ensure we forward the head so that using
// it later does not rewind the manual commit.
//
// This does not affect the first case, because dirty == head,
// and manually committing dirty changes nothing.
//
// This does not affect the second case, because effectively,
// this is just bumping head early (dirty == head, no change).
//
// This *could* affect the third case, because an
// autocommit could begin, followed by a mark rewind,
// followed by autocommit completion. We document that
// using marks to rewind is not recommended.
//
// The user could also muck the offsets with SetOffsets.
// We document that concurrent committing is not encouraged,
// we do not attempt to guard past that.
//
// w.r.t. leader epoch's, we document that modifying
// leader epoch's is not recommended.
if uncommit.head.Less(set) {
uncommit.head = set
}
topic[respPart.Partition] = uncommit
}
if debug {
if bytes.HasSuffix(b.Bytes(), []byte(", ")) {
b.Truncate(b.Len() - 2)
}
b.WriteString("], ")
}
}
if debug {
update := b.String()
update = strings.TrimSuffix(update, ", ") // trim trailing comma and space after final topic
g.cfg.logger.Log(LogLevelDebug, "updated committed", "group", g.cfg.group, "to", update)
}
}
func (g *groupConsumer) defaultCommitCallback(_ *Client, _ *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
if err != nil {
if !errors.Is(err, context.Canceled) {
g.cfg.logger.Log(LogLevelError, "default commit failed", "group", g.cfg.group, "err", err)
} else {
g.cfg.logger.Log(LogLevelDebug, "default commit canceled", "group", g.cfg.group)
}
return
}
for _, topic := range resp.Topics {
for _, partition := range topic.Partitions {
if err := kerr.ErrorForCode(partition.ErrorCode); err != nil {
g.cfg.logger.Log(LogLevelError, "in default commit: unable to commit offsets for topic partition",
"group", g.cfg.group,
"topic", topic.Topic,
"partition", partition.Partition,
"error", err)
}
}
}
}
func (g *groupConsumer) loopCommit() {
ticker := time.NewTicker(g.cfg.autocommitInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
case <-g.ctx.Done():
return
}
// We use the group context for the default autocommit; revokes
// use the client context so that we can be sure we commit even
// after the group context is canceled (which is the first
// thing that happens so as to quit the manage loop before
// leaving a group).
//
// We always commit only the head. If we are autocommitting
// dirty, then updateUncommitted updates the head to dirty
// offsets.
g.noCommitDuringJoinAndSync.RLock()
g.mu.Lock()
if !g.blockAuto {
uncommitted := g.getUncommittedLocked(true, false)
if len(uncommitted) == 0 {
g.cfg.logger.Log(LogLevelDebug, "skipping autocommit due to no offsets to commit", "group", g.cfg.group)
g.noCommitDuringJoinAndSync.RUnlock()
} else {
g.cfg.logger.Log(LogLevelDebug, "autocommitting", "group", g.cfg.group)
g.commit(g.ctx, uncommitted, func(cl *Client, req *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
g.noCommitDuringJoinAndSync.RUnlock()
g.cfg.commitCallback(cl, req, resp, err)
})
}
} else {
g.noCommitDuringJoinAndSync.RUnlock()
}
g.mu.Unlock()
}
}
// For SetOffsets, the gist of what follows:
//
// We need to set uncommitted.committed; that is the guarantee of this
// function. However, if, for everything we are setting, the head equals the
// commit, then we do not need to actually invalidate our current assignments.
// This is a great optimization for transactions that are resetting their state
// on abort.
func (g *groupConsumer) getSetAssigns(setOffsets map[string]map[int32]EpochOffset) (assigns map[string]map[int32]Offset) {
g.mu.Lock()
defer g.mu.Unlock()
groupTopics := g.tps.load()
if g.uncommitted == nil {
g.uncommitted = make(uncommitted)
}
for topic, partitions := range setOffsets {
if !groupTopics.hasTopic(topic) {
continue // trying to set a topic that was not assigned...
}
topicUncommitted := g.uncommitted[topic]
if topicUncommitted == nil {
topicUncommitted = make(map[int32]uncommit)
g.uncommitted[topic] = topicUncommitted
}
var topicAssigns map[int32]Offset
for partition, epochOffset := range partitions {
current, exists := topicUncommitted[partition]
topicUncommitted[partition] = uncommit{
dirty: epochOffset,
head: epochOffset,
committed: epochOffset,
}
if exists && current.dirty == epochOffset {
continue
} else if topicAssigns == nil {
topicAssigns = make(map[int32]Offset, len(partitions))
}
topicAssigns[partition] = Offset{
at: epochOffset.Offset,
epoch: epochOffset.Epoch,
}
}
if len(topicAssigns) > 0 {
if assigns == nil {
assigns = make(map[string]map[int32]Offset, 10)
}
assigns[topic] = topicAssigns
}
}
return assigns
}
// UncommittedOffsets returns the latest uncommitted offsets. Uncommitted
// offsets are always updated on calls to PollFetches.
//
// If there are no uncommitted offsets, this returns nil.
func (cl *Client) UncommittedOffsets() map[string]map[int32]EpochOffset {
if g := cl.consumer.g; g != nil {
return g.getUncommitted(true)
}
return nil
}
// MarkedOffsets returns the latest marked offsets. When autocommitting, a
// marked offset is an offset that can be committed, in comparison to a dirty
// offset that cannot yet be committed. MarkedOffsets returns nil if you are
// not using AutoCommitMarks.
func (cl *Client) MarkedOffsets() map[string]map[int32]EpochOffset {
g := cl.consumer.g
if g == nil || !cl.cfg.autocommitMarks {
return nil
}
return g.getUncommitted(false)
}
// CommittedOffsets returns the latest committed offsets. Committed offsets are
// updated from commits or from joining a group and fetching offsets.
//
// If there are no committed offsets, this returns nil.
func (cl *Client) CommittedOffsets() map[string]map[int32]EpochOffset {
g := cl.consumer.g
if g == nil {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
return g.getUncommittedLocked(false, false)
}
func (g *groupConsumer) getUncommitted(dirty bool) map[string]map[int32]EpochOffset {
g.mu.Lock()
defer g.mu.Unlock()
return g.getUncommittedLocked(true, dirty)
}
func (g *groupConsumer) getUncommittedLocked(head, dirty bool) map[string]map[int32]EpochOffset {
if g.uncommitted == nil {
return nil
}
var uncommitted map[string]map[int32]EpochOffset
for topic, partitions := range g.uncommitted {
var topicUncommitted map[int32]EpochOffset
for partition, uncommit := range partitions {
if head && (dirty && uncommit.dirty == uncommit.committed || !dirty && uncommit.head == uncommit.committed) {
continue
}
if topicUncommitted == nil {
if uncommitted == nil {
uncommitted = make(map[string]map[int32]EpochOffset, len(g.uncommitted))
}
topicUncommitted = uncommitted[topic]
if topicUncommitted == nil {
topicUncommitted = make(map[int32]EpochOffset, len(partitions))
uncommitted[topic] = topicUncommitted
}
}
if head {
if dirty {
topicUncommitted[partition] = uncommit.dirty
} else {
topicUncommitted[partition] = uncommit.head
}
} else {
topicUncommitted[partition] = uncommit.committed
}
}
}
return uncommitted
}
var commitContextFn = func() *string { s := "commit_ctx"; return &s }()
// PreCommitFnContext attaches fn to the context through WithValue. Using the
// context while committing allows fn to be called just before the commit is
// issued. This can be used to modify the actual commit, such as by associating
// metadata with partitions. If fn returns an error, the commit is not
// attempted.
func PreCommitFnContext(ctx context.Context, fn func(*kmsg.OffsetCommitRequest) error) context.Context {
return context.WithValue(ctx, commitContextFn, fn)
}
var txnCommitContextFn = func() *string { s := "txn_commit_ctx"; return &s }()
// PreTxnCommitFnContext attaches fn to the context through WithValue. Using
// the context while committing a transaction allows fn to be called just
// before the commit is issued. This can be used to modify the actual commit,
// such as by associating metadata with partitions (for transactions, the
// default internal metadata is the client's current member ID). If fn returns
// an error, the commit is not attempted. This context can be used in either
// GroupTransactSession.End or in Client.EndTransaction.
func PreTxnCommitFnContext(ctx context.Context, fn func(*kmsg.TxnOffsetCommitRequest) error) context.Context {
return context.WithValue(ctx, txnCommitContextFn, fn)
}
// CommitRecords issues a synchronous offset commit for the offsets contained
// within rs. Retryable errors are retried up to the configured retry limit,
// and any unretryable error is returned.
//
// This function is useful as a simple way to commit offsets if you have
// disabled autocommitting. As an alternative if you always want to commit
// everything, see CommitUncommittedOffsets.
//
// Simple usage of this function may lead to duplicate records if a consumer
// group rebalance occurs before or while this function is being executed. You
// can avoid this scenario by calling CommitRecords in a custom
// OnPartitionsRevoked, but for most workloads, a small bit of potential
// duplicate processing is fine. See the documentation on DisableAutoCommit
// for more details. You can also avoid this problem by using
// BlockRebalanceOnPoll, but that option comes with its own tradeoffs (refer to
// its documentation).
//
// It is recommended to always commit records in order (per partition). If you
// call this function twice with record for partition 0 at offset 999
// initially, and then with record for partition 0 at offset 4, you will rewind
// your commit.
//
// A use case for this function may be to partially process a batch of records,
// commit, and then continue to process the rest of the records. It is not
// recommended to call this for every record processed in a high throughput
// scenario, because you do not want to unnecessarily increase load on Kafka.
//
// If you do not want to wait for this function to complete before continuing
// processing records, you can call this function in a goroutine.
func (cl *Client) CommitRecords(ctx context.Context, rs ...*Record) error {
// First build the offset commit map. We favor the latest epoch, then
// offset, if any records map to the same topic / partition.
offsets := make(map[string]map[int32]EpochOffset)
for _, r := range rs {
toffsets := offsets[r.Topic]
if toffsets == nil {
toffsets = make(map[int32]EpochOffset)
offsets[r.Topic] = toffsets
}
set := EpochOffset{
r.LeaderEpoch,
r.Offset + 1, // need to advice to next offset to move forward
}
if at, exists := toffsets[r.Partition]; exists {
if set.Less(at) {
continue
}
}
toffsets[r.Partition] = set
}
var rerr error // return error
// Our client retries an OffsetCommitRequest as necessary if the first
// response partition has a retryable group error (group coordinator
// loading, etc), so any partition error is fatal.
cl.CommitOffsetsSync(ctx, offsets, func(_ *Client, _ *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
if err != nil {
rerr = err
return
}
for _, topic := range resp.Topics {
for _, partition := range topic.Partitions {
if err := kerr.ErrorForCode(partition.ErrorCode); err != nil {
rerr = err
return
}
}
}
})
return rerr
}
// MarkCommitRecords marks records to be available for autocommitting. This
// function is only useful if you use the AutoCommitMarks config option, see
// the documentation on that option for more details. This function does not
// allow rewinds.
func (cl *Client) MarkCommitRecords(rs ...*Record) {
g := cl.consumer.g
if g == nil || !cl.cfg.autocommitMarks {
return
}
sort.Slice(rs, func(i, j int) bool {
return rs[i].Topic < rs[j].Topic ||
rs[i].Topic == rs[j].Topic && rs[i].Partition < rs[j].Partition
})
// protect g.uncommitted map
g.mu.Lock()
defer g.mu.Unlock()
if g.uncommitted == nil {
g.uncommitted = make(uncommitted)
}
var curTopic string
var curPartitions map[int32]uncommit
for _, r := range rs {
if curPartitions == nil || r.Topic != curTopic {
curPartitions = g.uncommitted[r.Topic]
if curPartitions == nil {
curPartitions = make(map[int32]uncommit)
g.uncommitted[r.Topic] = curPartitions
}
curTopic = r.Topic
}
current, ok := curPartitions[r.Partition]
if newHead := (EpochOffset{
r.LeaderEpoch,
r.Offset + 1,
}); !ok || current.head.Less(newHead) {
curPartitions[r.Partition] = uncommit{
dirty: current.dirty,
committed: current.committed,
head: newHead,
}
}
}
}
// MarkCommitOffsets marks offsets to be available for autocommitting. This
// function is only useful if you use the AutoCommitMarks config option, see
// the documentation on that option for more details. This function does not
// allow rewinds.
func (cl *Client) MarkCommitOffsets(unmarked map[string]map[int32]EpochOffset) {
g := cl.consumer.g
if g == nil || !cl.cfg.autocommitMarks {
return
}
// protect g.uncommitted map
g.mu.Lock()
defer g.mu.Unlock()
if g.uncommitted == nil {
g.uncommitted = make(uncommitted)
}
for topic, partitions := range unmarked {
curPartitions := g.uncommitted[topic]
if curPartitions == nil {
curPartitions = make(map[int32]uncommit)
g.uncommitted[topic] = curPartitions
}
for partition, newHead := range partitions {
current, ok := curPartitions[partition]
if !ok || current.head.Less(newHead) {
curPartitions[partition] = uncommit{
dirty: current.dirty,
committed: current.committed,
head: newHead,
}
}
}
}
}
// CommitUncommittedOffsets issues a synchronous offset commit for any
// partition that has been consumed from that has uncommitted offsets.
// Retryable errors are retried up to the configured retry limit, and any
// unretryable error is returned.
//
// The recommended pattern for using this function is to have a poll / process
// / commit loop. First PollFetches, then process every record, then call
// CommitUncommittedOffsets.
//
// As an alternative if you want to commit specific records, see CommitRecords.
func (cl *Client) CommitUncommittedOffsets(ctx context.Context) error {
// This function is just the tail end of CommitRecords just above.
return cl.commitOffsets(ctx, cl.UncommittedOffsets())
}
// CommitMarkedOffsets issues a synchronous offset commit for any partition
// that has been consumed from that has marked offsets. Retryable errors are
// retried up to the configured retry limit, and any unretryable error is
// returned.
//
// This function is only useful if you have marked offsets with
// MarkCommitRecords when using AutoCommitMarks, otherwise this is a no-op.
//
// The recommended pattern for using this function is to have a poll / process
// / commit loop. First PollFetches, then process every record,
// call MarkCommitRecords for the records you wish the commit and then call
// CommitMarkedOffsets.
//
// As an alternative if you want to commit specific records, see CommitRecords.
func (cl *Client) CommitMarkedOffsets(ctx context.Context) error {
// This function is just the tail end of CommitRecords just above.
marked := cl.MarkedOffsets()
if len(marked) == 0 {
return nil
}
return cl.commitOffsets(ctx, marked)
}
func (cl *Client) commitOffsets(ctx context.Context, offsets map[string]map[int32]EpochOffset) error {
var rerr error
cl.CommitOffsetsSync(ctx, offsets, func(_ *Client, _ *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
if err != nil {
rerr = err
return
}
for _, topic := range resp.Topics {
for _, partition := range topic.Partitions {
if err := kerr.ErrorForCode(partition.ErrorCode); err != nil {
rerr = err
return
}
}
}
})
return rerr
}
// CommitOffsetsSync cancels any active CommitOffsets, begins a commit that
// cannot be canceled, and waits for that commit to complete. This function
// will not return until the commit is done and the onDone callback is
// complete.
//
// The purpose of this function is for use in OnPartitionsRevoked or committing
// before leaving a group, because you do not want to have a commit issued in
// OnPartitionsRevoked canceled.
//
// This is an advanced function, and for simpler, more easily understandable
// committing, see CommitRecords and CommitUncommittedOffsets.
//
// For more information about committing and committing asynchronously, see
// CommitOffsets.
func (cl *Client) CommitOffsetsSync(
ctx context.Context,
uncommitted map[string]map[int32]EpochOffset,
onDone func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error),
) {
if onDone == nil {
onDone = func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error) {}
}
g := cl.consumer.g
if g == nil {
onDone(cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), errNotGroup)
return
}
if len(uncommitted) == 0 {
onDone(cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), nil)
return
}
g.commitOffsetsSync(ctx, uncommitted, onDone)
}
// waitJoinSyncMu is a rather insane way to try to grab a lock, but also return
// early if we have to wait and the context is canceled.
func (g *groupConsumer) waitJoinSyncMu(ctx context.Context) error {
if g.noCommitDuringJoinAndSync.TryRLock() {
g.cfg.logger.Log(LogLevelDebug, "grabbed join/sync mu on first try")
return nil
}
var (
blockJoinSyncCh = make(chan struct{})
mu sync.Mutex
returned bool
maybeRUnlock = func() {
mu.Lock()
defer mu.Unlock()
if returned {
g.noCommitDuringJoinAndSync.RUnlock()
}
returned = true
}
)
go func() {
g.noCommitDuringJoinAndSync.RLock()
close(blockJoinSyncCh)
maybeRUnlock()
}()
select {
case <-blockJoinSyncCh:
g.cfg.logger.Log(LogLevelDebug, "grabbed join/sync mu after waiting")
return nil
case <-ctx.Done():
g.cfg.logger.Log(LogLevelDebug, "not grabbing mu because context canceled")
maybeRUnlock()
return ctx.Err()
}
}
func (g *groupConsumer) commitOffsetsSync(
ctx context.Context,
uncommitted map[string]map[int32]EpochOffset,
onDone func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error),
) {
g.cfg.logger.Log(LogLevelDebug, "in CommitOffsetsSync", "group", g.cfg.group, "with", uncommitted)
defer g.cfg.logger.Log(LogLevelDebug, "left CommitOffsetsSync", "group", g.cfg.group)
done := make(chan struct{})
defer func() { <-done }()
if onDone == nil {
onDone = func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error) {}
}
if err := g.waitJoinSyncMu(ctx); err != nil {
onDone(g.cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), err)
close(done)
return
}
g.syncCommitMu.Lock() // block all other concurrent commits until our OnDone is done.
unblockCommits := func(cl *Client, req *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
g.noCommitDuringJoinAndSync.RUnlock()
defer close(done)
defer g.syncCommitMu.Unlock()
onDone(cl, req, resp, err)
}
g.mu.Lock()
defer g.mu.Unlock()
g.blockAuto = true
unblockAuto := func(cl *Client, req *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
unblockCommits(cl, req, resp, err)
g.mu.Lock()
defer g.mu.Unlock()
g.blockAuto = false
}
g.commit(ctx, uncommitted, unblockAuto)
}
// CommitOffsets commits the given offsets for a group, calling onDone with the
// commit request and either the response or an error if the response was not
// issued. If uncommitted is empty or the client is not consuming as a group,
// onDone is called with (nil, nil, nil) and this function returns immediately.
// It is OK if onDone is nil, but you will not know if your commit succeeded.
//
// This is an advanced function and is difficult to use correctly. For simpler,
// more easily understandable committing, see CommitRecords and
// CommitUncommittedOffsets.
//
// This function itself does not wait for the commit to finish. By default,
// this function is an asynchronous commit. You can use onDone to make it sync.
// If autocommitting is enabled, this function blocks autocommitting until this
// function is complete and the onDone has returned.
//
// It is invalid to use this function to commit offsets for a transaction.
//
// Note that this function ensures absolute ordering of commit requests by
// canceling prior requests and ensuring they are done before executing a new
// one. This means, for absolute control, you can use this function to
// periodically commit async and then issue a final sync commit before quitting
// (this is the behavior of autocommiting and using the default revoke). This
// differs from the Java async commit, which does not retry requests to avoid
// trampling on future commits.
//
// It is highly recommended to check the response's partition's error codes if
// the response is non-nil. While unlikely, individual partitions can error.
// This is most likely to happen if a commit occurs too late in a rebalance
// event.
//
// Do not use this async CommitOffsets in OnPartitionsRevoked, instead use
// CommitOffsetsSync. If you commit async, the rebalance will proceed before
// this function executes, and you will commit offsets for partitions that have
// moved to a different consumer.
func (cl *Client) CommitOffsets(
ctx context.Context,
uncommitted map[string]map[int32]EpochOffset,
onDone func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error),
) {
cl.cfg.logger.Log(LogLevelDebug, "in CommitOffsets", "with", uncommitted)
defer cl.cfg.logger.Log(LogLevelDebug, "left CommitOffsets")
if onDone == nil {
onDone = func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error) {}
}
g := cl.consumer.g
if g == nil {
onDone(cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), errNotGroup)
return
}
if len(uncommitted) == 0 {
onDone(cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), nil)
return
}
if err := g.waitJoinSyncMu(ctx); err != nil {
onDone(g.cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), err)
return
}
g.syncCommitMu.RLock() // block sync commit, but allow other concurrent Commit to cancel us
unblockJoinSync := func(cl *Client, req *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
g.noCommitDuringJoinAndSync.RUnlock()
defer g.syncCommitMu.RUnlock()
onDone(cl, req, resp, err)
}
g.mu.Lock()
defer g.mu.Unlock()
g.blockAuto = true
unblockAuto := func(cl *Client, req *kmsg.OffsetCommitRequest, resp *kmsg.OffsetCommitResponse, err error) {
unblockJoinSync(cl, req, resp, err)
g.mu.Lock()
defer g.mu.Unlock()
g.blockAuto = false
}
g.commit(ctx, uncommitted, unblockAuto)
}
// defaultRevoke commits the last fetched offsets and waits for the commit to
// finish. This is the default onRevoked function which, when combined with the
// default autocommit, ensures we never miss committing everything.
//
// Note that the heartbeat loop invalidates all buffered, unpolled fetches
// before revoking, meaning this truly will commit all polled fetches.
func (g *groupConsumer) defaultRevoke(context.Context, *Client, map[string][]int32) {
if !g.cfg.autocommitDisable {
// We use the client's context rather than the group context,
// because this could come from the group being left. The group
// context will already be canceled.
g.commitOffsetsSync(g.cl.ctx, g.getUncommitted(false), g.cfg.commitCallback)
}
}
// The actual logic to commit. This is called under two locks:
// - g.noCommitDuringJoinAndSync.RLock()
// - g.mu.Lock()
//
// By blocking the JoinGroup from being issued, or blocking the commit on join
// & sync finishing, we avoid RebalanceInProgress and IllegalGeneration. The
// former error happens if a commit arrives to the broker between the two, the
// latter error happens when a commit arrives to the broker with the old
// generation (it was in flight before sync finished).
//
// Practically, what this means is that a user's commits will be blocked if
// they try to commit between join and sync.
//
// For eager consuming, the user should not have any partitions to commit
// anyway. For cooperative consuming, a rebalance can happen after at any
// moment. We block only revokation aspects of rebalances with
// BlockRebalanceOnPoll; we want to allow the cooperative part of rebalancing
// to occur.
func (g *groupConsumer) commit(
ctx context.Context,
uncommitted map[string]map[int32]EpochOffset,
onDone func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error),
) {
// The user could theoretically give us topics that have no partitions
// to commit. We strip those: Kafka does not reply to them, and we
// expect all partitions in our request to be replied to in
// updateCommitted. If any topic is empty, we deeply clone and then
// strip everything empty. See #186.
var clone bool
for _, ps := range uncommitted {
if len(ps) == 0 {
clone = true
break
}
}
if clone {
dup := make(map[string]map[int32]EpochOffset, len(uncommitted))
for t, ps := range uncommitted {
if len(ps) == 0 {
continue
}
dupPs := make(map[int32]EpochOffset, len(ps))
dup[t] = dupPs
maps.Copy(dupPs, ps)
}
uncommitted = dup
}
if len(uncommitted) == 0 { // only empty if called thru autocommit / default revoke
// We have to do this concurrently because the expectation is
// that commit itself does not block.
go onDone(g.cl, kmsg.NewPtrOffsetCommitRequest(), kmsg.NewPtrOffsetCommitResponse(), nil)
return
}
priorCancel := g.commitCancel
priorDone := g.commitDone
commitCtx, commitCancel := context.WithCancel(ctx) // enable ours to be canceled and waited for
commitDone := make(chan struct{})
g.commitCancel = commitCancel
g.commitDone = commitDone
req := kmsg.NewPtrOffsetCommitRequest()
req.Group = g.cfg.group
memberID, generation := g.memberGen.load()
req.Generation = generation
req.MemberID = memberID
req.InstanceID = g.cfg.instanceID
is848 := g.is848 // safe since we are under g.mu
if ctx.Done() != nil {
go func() {
select {
case <-ctx.Done():
commitCancel()
case <-commitCtx.Done():
}
}()
}
go func() {
defer close(commitDone) // allow future commits to continue when we are done
defer commitCancel()
if priorDone != nil { // wait for any prior request to finish
select {
case <-priorDone:
default:
g.cfg.logger.Log(LogLevelDebug, "canceling prior commit to issue another", "group", g.cfg.group)
priorCancel()
<-priorDone
}
}
g.cfg.logger.Log(LogLevelDebug, "issuing commit", "group", g.cfg.group, "uncommitted", uncommitted)
for topic, partitions := range uncommitted {
reqTopic := kmsg.NewOffsetCommitRequestTopic()
reqTopic.Topic = topic
for partition, eo := range partitions {
reqPartition := kmsg.NewOffsetCommitRequestTopicPartition()
reqPartition.Partition = partition
reqPartition.Offset = eo.Offset
reqPartition.LeaderEpoch = eo.Epoch // KIP-320
reqPartition.Metadata = &req.MemberID
reqTopic.Partitions = append(reqTopic.Partitions, reqPartition)
}
req.Topics = append(req.Topics, reqTopic)
}
if fn, ok := ctx.Value(commitContextFn).(func(*kmsg.OffsetCommitRequest) error); ok {
if err := fn(req); err != nil {
onDone(g.cl, req, nil, err)
return
}
}
var retries int
issue:
start := time.Now()
resp, err := req.RequestWith(commitCtx, g.cl)
if err != nil {
onDone(g.cl, req, nil, err)
return
}
g.cl.metrics.observeTime(&g.cl.metrics.cCommitLatency, time.Since(start).Milliseconds())
// With next gen consumer groups, it is possible for the group
// to rebalance again immediately after we discover we need to
// revoke. We try up to 3x reloading and resending.
if is848 && len(resp.Topics) > 0 && len(resp.Topics[0].Partitions) > 0 {
ec := resp.Topics[0].Partitions[0].ErrorCode
if kerr.ErrorForCode(ec) == kerr.StaleMemberEpoch && retries < 3 {
hbreq := g.g848.mkreq()
hbresp, err := hbreq.RequestWith(commitCtx, g.cl)
if err != nil {
g.cl.cfg.logger.Log(LogLevelInfo, "received STALE_MEMBER_EPOCH while committing; unable to force heartbeat; returning original stale error",
"last_generation", generation,
"err", err,
)
} else {
if err := kerr.ErrorForCode(hbresp.ErrorCode); err != nil { //nolint:revive // err != nil is more standard as the first case
g.cl.cfg.logger.Log(LogLevelInfo, "received STALE_MEMBER_EPOCH while committing; forced immediate heartbeat to load the latest generation but received an error",
"last_generation", generation,
"err", err,
)
} else {
g.cl.cfg.logger.Log(LogLevelInfo, "received STALE_MEMBER_EPOCH while committing; forced immediate heartbeat to load the latest generation",
"last_generation", generation,
"new_generation", hbresp.MemberEpoch,
)
g.memberGen.storeGeneration(hbresp.MemberEpoch)
generation = hbresp.MemberEpoch
req.Generation = generation
retries++
goto issue
}
}
}
}
g.updateCommitted(req, resp)
onDone(g.cl, req, resp, nil)
}()
}
type reNews struct {
added map[string][]string
skipped []string
}
func (r *reNews) add(re, match string) {
if r.added == nil {
r.added = make(map[string][]string)
}
r.added[re] = append(r.added[re], match)
}
func (r *reNews) skip(topic string) {
r.skipped = append(r.skipped, topic)
}
func (r *reNews) log(cfg *cfg) {
if len(r.added) == 0 && len(r.skipped) == 0 {
return
}
var addeds []string
for re, matches := range r.added {
sort.Strings(matches)
addeds = append(addeds, fmt.Sprintf("%s[%s]", re, strings.Join(matches, " ")))
}
added := strings.Join(addeds, " ")
sort.Strings(r.skipped)
cfg.logger.Log(LogLevelInfo, "consumer regular expressions evaluated on new topics", "added", added, "evaluated_and_skipped", r.skipped)
}