Files
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c855109044 build(deps): bump github.com/twmb/franz-go/pkg/kadm (#5800)
Bumps [github.com/twmb/franz-go/pkg/kadm](https://github.com/twmb/franz-go) from 1.16.0 to 1.17.1.
- [Changelog](https://github.com/twmb/franz-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/twmb/franz-go/compare/v1.16.0...v1.17.1)

---
updated-dependencies:
- dependency-name: github.com/twmb/franz-go/pkg/kadm
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-05 08:38:06 +00:00

2393 lines
71 KiB
Go

package kadm
import (
"context"
"errors"
"fmt"
"sort"
"github.com/twmb/franz-go/pkg/kerr"
"github.com/twmb/franz-go/pkg/kgo"
"github.com/twmb/franz-go/pkg/kmsg"
)
// GroupMemberMetadata is the metadata that a client sent in a JoinGroup request.
// This can have one of three types:
//
// *kmsg.ConsumerMemberMetadata, if the group's ProtocolType is "consumer"
// *kmsg.ConnectMemberMetadata, if the group's ProtocolType is "connect"
// []byte, if the group's ProtocolType is unknown
type GroupMemberMetadata struct{ i any }
// AsConsumer returns the metadata as a ConsumerMemberMetadata if possible.
func (m GroupMemberMetadata) AsConsumer() (*kmsg.ConsumerMemberMetadata, bool) {
c, ok := m.i.(*kmsg.ConsumerMemberMetadata)
return c, ok
}
// AsConnect returns the metadata as ConnectMemberMetadata if possible.
func (m GroupMemberMetadata) AsConnect() (*kmsg.ConnectMemberMetadata, bool) {
c, ok := m.i.(*kmsg.ConnectMemberMetadata)
return c, ok
}
// Raw returns the metadata as a raw byte slice, if it is neither of consumer
// type nor connect type.
func (m GroupMemberMetadata) Raw() ([]byte, bool) {
c, ok := m.i.([]byte)
return c, ok
}
// GroupMemberAssignment is the assignment that a leader sent / a member
// received in a SyncGroup request. This can have one of three types:
//
// *kmsg.ConsumerMemberAssignment, if the group's ProtocolType is "consumer"
// *kmsg.ConnectMemberAssignment, if the group's ProtocolType is "connect"
// []byte, if the group's ProtocolType is unknown
type GroupMemberAssignment struct{ i any }
// AsConsumer returns the assignment as a ConsumerMemberAssignment if possible.
func (m GroupMemberAssignment) AsConsumer() (*kmsg.ConsumerMemberAssignment, bool) {
c, ok := m.i.(*kmsg.ConsumerMemberAssignment)
return c, ok
}
// AsConnect returns the assignment as ConnectMemberAssignment if possible.
func (m GroupMemberAssignment) AsConnect() (*kmsg.ConnectMemberAssignment, bool) {
c, ok := m.i.(*kmsg.ConnectMemberAssignment)
return c, ok
}
// Raw returns the assignment as a raw byte slice, if it is neither of consumer
// type nor connect type.
func (m GroupMemberAssignment) Raw() ([]byte, bool) {
c, ok := m.i.([]byte)
return c, ok
}
// DescribedGroupMember is the detail of an individual group member as returned
// by a describe groups response.
type DescribedGroupMember struct {
MemberID string // MemberID is the Kafka assigned member ID of this group member.
InstanceID *string // InstanceID is a potential user assigned instance ID of this group member (KIP-345).
ClientID string // ClientID is the Kafka client given ClientID of this group member.
ClientHost string // ClientHost is the host this member is running on.
Join GroupMemberMetadata // Join is what this member sent in its join group request; what it wants to consume.
Assigned GroupMemberAssignment // Assigned is what this member was assigned to consume by the leader.
}
// AssignedPartitions returns the set of unique topics and partitions that are
// assigned across all members in this group.
//
// This function is only relevant if the group is of type "consumer".
func (d *DescribedGroup) AssignedPartitions() TopicsSet {
s := make(TopicsSet)
for _, m := range d.Members {
if c, ok := m.Assigned.AsConsumer(); ok {
for _, t := range c.Topics {
s.Add(t.Topic, t.Partitions...)
}
}
}
return s
}
// JoinTopics returns the set of topics that all members are interested in
// consuming.
//
// This function is only relevant for groups of time "consumer".
func (d DescribedGroup) JoinTopics() []string {
s := make(map[string]struct{})
for _, m := range d.Members {
if c, ok := m.Join.AsConsumer(); ok {
for _, t := range c.Topics {
s[t] = struct{}{}
}
}
}
ks := make([]string, 0, len(s))
for k := range s {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// DescribedGroup contains data from a describe groups response for a single
// group.
type DescribedGroup struct {
Group string // Group is the name of the described group.
Coordinator BrokerDetail // Coordinator is the coordinator broker for this group.
State string // State is the state this group is in (Empty, Dead, Stable, etc.).
ProtocolType string // ProtocolType is the type of protocol the group is using, "consumer" for normal consumers, "connect" for Kafka connect.
Protocol string // Protocol is the partition assignor strategy this group is using.
Members []DescribedGroupMember // Members contains the members of this group sorted first by InstanceID, or if nil, by MemberID.
AuthorizedOperations []ACLOperation // AuthorizedOperations contains operations the requesting client is allowed to perform on this group.
Err error // Err is non-nil if the group could not be described.
ErrMessage string // ErrMessage a potential extra message describing any error.
}
// DescribedGroups contains data for multiple groups from a describe groups
// response.
type DescribedGroups map[string]DescribedGroup
// AssignedPartitions returns the set of unique topics and partitions that are
// assigned across all members in all groups. This is the all-group analogue to
// DescribedGroup.AssignedPartitions.
//
// This function is only relevant for groups of type "consumer".
func (ds DescribedGroups) AssignedPartitions() TopicsSet {
s := make(TopicsSet)
for _, g := range ds {
for _, m := range g.Members {
if c, ok := m.Assigned.AsConsumer(); ok {
for _, t := range c.Topics {
s.Add(t.Topic, t.Partitions...)
}
}
}
}
return s
}
// JoinTopics returns the set of topics that all members are interested in
// consuming. This is the all-group analogue to DescribedGroup.JoinTopics.
//
// This function is only relevant for groups of time "consumer".
func (ds DescribedGroups) JoinTopics() []string {
s := make(map[string]struct{})
for _, g := range ds {
for _, m := range g.Members {
if c, ok := m.Join.AsConsumer(); ok {
for _, t := range c.Topics {
s[t] = struct{}{}
}
}
}
}
ks := make([]string, 0, len(s))
for k := range s {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// Sorted returns all groups sorted by group name.
func (ds DescribedGroups) Sorted() []DescribedGroup {
s := make([]DescribedGroup, 0, len(ds))
for _, d := range ds {
s = append(s, d)
}
sort.Slice(s, func(i, j int) bool { return s[i].Group < s[j].Group })
return s
}
// On calls fn for the group if it exists, returning the group and the error
// returned from fn. If fn is nil, this simply returns the group.
//
// The fn is given a shallow copy of the group. This function returns the copy
// as well; any modifications within fn are modifications on the returned copy.
// Modifications on a described group's inner fields are persisted to the
// original map (because slices are pointers).
//
// If the group does not exist, this returns kerr.GroupIDNotFound.
func (ds DescribedGroups) On(group string, fn func(*DescribedGroup) error) (DescribedGroup, error) {
if len(ds) > 0 {
r, ok := ds[group]
if ok {
if fn == nil {
return r, nil
}
return r, fn(&r)
}
}
return DescribedGroup{}, kerr.GroupIDNotFound
}
// Error iterates over all groups and returns the first error encountered, if
// any.
func (ds DescribedGroups) Error() error {
for _, d := range ds {
if d.Err != nil {
return d.Err
}
}
return nil
}
// Names returns a sorted list of all group names.
func (ds DescribedGroups) Names() []string {
all := make([]string, 0, len(ds))
for g := range ds {
all = append(all, g)
}
sort.Strings(all)
return all
}
// ListedGroup contains data from a list groups response for a single group.
type ListedGroup struct {
Coordinator int32 // Coordinator is the node ID of the coordinator for this group.
Group string // Group is the name of this group.
ProtocolType string // ProtocolType is the type of protocol the group is using, "consumer" for normal consumers, "connect" for Kafka connect.
State string // State is the state this group is in (Empty, Dead, Stable, etc.; only if talking to Kafka 2.6+).
}
// ListedGroups contains information from a list groups response.
type ListedGroups map[string]ListedGroup
// Sorted returns all groups sorted by group name.
func (ls ListedGroups) Sorted() []ListedGroup {
s := make([]ListedGroup, 0, len(ls))
for _, l := range ls {
s = append(s, l)
}
sort.Slice(s, func(i, j int) bool { return s[i].Group < s[j].Group })
return s
}
// Groups returns a sorted list of all group names.
func (ls ListedGroups) Groups() []string {
all := make([]string, 0, len(ls))
for g := range ls {
all = append(all, g)
}
sort.Strings(all)
return all
}
// ListGroups returns all groups in the cluster. If you are talking to Kafka
// 2.6+, filter states can be used to return groups only in the requested
// states. By default, this returns all groups. In almost all cases,
// DescribeGroups is more useful.
//
// This may return *ShardErrors or *AuthError.
func (cl *Client) ListGroups(ctx context.Context, filterStates ...string) (ListedGroups, error) {
return cl.ListGroupsByType(ctx, nil, filterStates...)
}
// ListGroupsByType returns all groups in the cluster, filtered by the given group
// type (classic, consumer, share, streams). Filter states can be used to
// further filter by the current group state. Requires Kafka 3.8+.
//
// This may return *ShardErrors or *AuthError.
func (cl *Client) ListGroupsByType(ctx context.Context, types []string, filterStates ...string) (ListedGroups, error) {
req := kmsg.NewPtrListGroupsRequest()
req.StatesFilter = append(req.StatesFilter, filterStates...)
req.TypesFilter = append(req.TypesFilter, types...)
shards := cl.cl.RequestSharded(ctx, req)
list := make(ListedGroups)
return list, shardErrEachBroker(req, shards, func(b BrokerDetail, kr kmsg.Response) error {
resp := kr.(*kmsg.ListGroupsResponse)
if err := maybeAuthErr(resp.ErrorCode); err != nil {
return err
}
if err := kerr.ErrorForCode(resp.ErrorCode); err != nil {
return err
}
for _, g := range resp.Groups {
list[g.Group] = ListedGroup{ // group only lives on one broker, no need to exist-check
Coordinator: b.NodeID,
Group: g.Group,
ProtocolType: g.ProtocolType,
State: g.GroupState,
}
}
return nil
})
}
// DescribeGroups describes either all classic groups specified, or all classic
// groups in the cluster if none are specified.
//
// This may return *ShardErrors or *AuthError.
//
// If no groups are specified and this method first lists groups, and list
// groups returns a *ShardErrors, this function describes all successfully
// listed groups and appends the list shard errors to any describe shard
// errors.
//
// If only one group is described, there will be at most one request issued,
// and there is no need to deeply inspect the error.
func (cl *Client) DescribeGroups(ctx context.Context, groups ...string) (DescribedGroups, error) {
var seList *ShardErrors
if len(groups) == 0 {
listed, err := cl.ListGroupsByType(ctx, []string{"classic"})
switch {
case err == nil:
case errors.As(err, &seList):
default:
return nil, err
}
groups = listed.Groups()
if len(groups) == 0 {
return nil, err
}
}
req := kmsg.NewPtrDescribeGroupsRequest()
req.Groups = groups
req.IncludeAuthorizedOperations = true
shards := cl.cl.RequestSharded(ctx, req)
described := make(DescribedGroups)
err := shardErrEachBroker(req, shards, func(b BrokerDetail, kr kmsg.Response) error {
resp := kr.(*kmsg.DescribeGroupsResponse)
for _, rg := range resp.Groups {
if err := maybeAuthErr(rg.ErrorCode); err != nil {
return err
}
g := DescribedGroup{
Group: rg.Group,
Coordinator: b,
State: rg.State,
ProtocolType: rg.ProtocolType,
Protocol: rg.Protocol,
AuthorizedOperations: DecodeACLOperations(rg.AuthorizedOperations),
Err: kerr.ErrorForCode(rg.ErrorCode),
ErrMessage: unptrStr(rg.ErrorMessage),
}
for _, rm := range rg.Members {
gm := DescribedGroupMember{
MemberID: rm.MemberID,
InstanceID: rm.InstanceID,
ClientID: rm.ClientID,
ClientHost: rm.ClientHost,
}
var mi, ai any
switch g.ProtocolType {
case "consumer":
m := new(kmsg.ConsumerMemberMetadata)
a := new(kmsg.ConsumerMemberAssignment)
m.ReadFrom(rm.ProtocolMetadata)
a.ReadFrom(rm.MemberAssignment)
mi, ai = m, a
case "connect":
m := new(kmsg.ConnectMemberMetadata)
a := new(kmsg.ConnectMemberAssignment)
m.ReadFrom(rm.ProtocolMetadata)
a.ReadFrom(rm.MemberAssignment)
mi, ai = m, a
default:
mi, ai = rm.ProtocolMetadata, rm.MemberAssignment
}
gm.Join = GroupMemberMetadata{mi}
gm.Assigned = GroupMemberAssignment{ai}
g.Members = append(g.Members, gm)
}
sort.Slice(g.Members, func(i, j int) bool {
if g.Members[i].InstanceID != nil {
if g.Members[j].InstanceID == nil {
return true
}
return *g.Members[i].InstanceID < *g.Members[j].InstanceID
}
if g.Members[j].InstanceID != nil {
return false
}
return g.Members[i].MemberID < g.Members[j].MemberID
})
described[g.Group] = g // group only lives on one broker, no need to exist-check
}
return nil
})
var seDesc *ShardErrors
switch {
case err == nil:
return described, seList.into()
case errors.As(err, &seDesc):
if seList != nil {
seDesc.Errs = append(seList.Errs, seDesc.Errs...)
}
return described, seDesc.into()
default:
return nil, err
}
}
// DeleteGroupResponse contains the response for an individual deleted group.
type DeleteGroupResponse struct {
Group string // Group is the group this response is for.
Err error // Err is non-nil if the group failed to be deleted.
}
// DeleteGroupResponses contains per-group responses to deleted groups.
type DeleteGroupResponses map[string]DeleteGroupResponse
// Sorted returns all deleted group responses sorted by group name.
func (ds DeleteGroupResponses) Sorted() []DeleteGroupResponse {
s := make([]DeleteGroupResponse, 0, len(ds))
for _, d := range ds {
s = append(s, d)
}
sort.Slice(s, func(i, j int) bool { return s[i].Group < s[j].Group })
return s
}
// On calls fn for the response group if it exists, returning the response and
// the error returned from fn. If fn is nil, this simply returns the group.
//
// The fn is given a copy of the response. This function returns the copy as
// well; any modifications within fn are modifications on the returned copy.
//
// If the group does not exist, this returns kerr.GroupIDNotFound.
func (ds DeleteGroupResponses) On(group string, fn func(*DeleteGroupResponse) error) (DeleteGroupResponse, error) {
if len(ds) > 0 {
r, ok := ds[group]
if ok {
if fn == nil {
return r, nil
}
return r, fn(&r)
}
}
return DeleteGroupResponse{}, kerr.GroupIDNotFound
}
// Error iterates over all groups and returns the first error encountered, if
// any.
func (ds DeleteGroupResponses) Error() error {
for _, r := range ds {
if r.Err != nil {
return r.Err
}
}
return nil
}
// DeleteGroup deletes the specified group. This is similar to DeleteGroups,
// but returns the kerr.ErrorForCode(response.ErrorCode) if the request/response
// is successful.
func (cl *Client) DeleteGroup(ctx context.Context, group string) (DeleteGroupResponse, error) {
rs, err := cl.DeleteGroups(ctx, group)
if err != nil {
return DeleteGroupResponse{}, err
}
g, exists := rs[group]
if !exists {
return DeleteGroupResponse{}, errors.New("requested group was not part of the delete group response")
}
return g, g.Err
}
// DeleteGroups deletes all groups specified.
//
// The purpose of this request is to allow operators a way to delete groups
// after Kafka 1.1, which removed RetentionTimeMillis from offset commits. See
// KIP-229 for more details.
//
// This may return *ShardErrors. This does not return on authorization
// failures, instead, authorization failures are included in the responses.
func (cl *Client) DeleteGroups(ctx context.Context, groups ...string) (DeleteGroupResponses, error) {
if len(groups) == 0 {
return nil, nil
}
req := kmsg.NewPtrDeleteGroupsRequest()
req.Groups = append(req.Groups, groups...)
shards := cl.cl.RequestSharded(ctx, req)
rs := make(map[string]DeleteGroupResponse)
return rs, shardErrEach(req, shards, func(kr kmsg.Response) error {
resp := kr.(*kmsg.DeleteGroupsResponse)
for _, g := range resp.Groups {
rs[g.Group] = DeleteGroupResponse{ // group is always on one broker, no need to exist-check
Group: g.Group,
Err: kerr.ErrorForCode(g.ErrorCode),
}
}
return nil
})
}
// LeaveGroupBuilder helps build a leave group request, rather than having
// a function signature (string, string, ...string).
//
// All functions on this type accept and return the same pointer, allowing
// for easy build-and-use usage.
type LeaveGroupBuilder struct {
group string
reason *string
instanceIDs []*string
}
// LeaveGroup returns a LeaveGroupBuilder for the input group.
func LeaveGroup(group string) *LeaveGroupBuilder {
return &LeaveGroupBuilder{
group: group,
}
}
// Reason attaches a reason to all members in the leave group request.
// This requires Kafka 3.2+.
func (b *LeaveGroupBuilder) Reason(reason string) *LeaveGroupBuilder {
b.reason = StringPtr(reason)
return b
}
// InstanceIDs are members to remove from a group.
func (b *LeaveGroupBuilder) InstanceIDs(ids ...string) *LeaveGroupBuilder {
for _, id := range ids {
if id != "" {
b.instanceIDs = append(b.instanceIDs, StringPtr(id))
}
}
return b
}
// LeaveGroupResponse contains the response for an individual instance ID that
// left a group.
type LeaveGroupResponse struct {
Group string // Group is the group that was left.
InstanceID string // InstanceID is the instance ID that left the group.
MemberID string // MemberID is the member ID that left the group.
Err error // Err is non-nil if this member did not exist.
}
// LeaveGroupResponses contains responses for each member of a leave group
// request. The map key is the instance ID that was removed from the group.
type LeaveGroupResponses map[string]LeaveGroupResponse
// Sorted returns all removed group members by instance ID.
func (ls LeaveGroupResponses) Sorted() []LeaveGroupResponse {
s := make([]LeaveGroupResponse, 0, len(ls))
for _, l := range ls {
s = append(s, l)
}
sort.Slice(s, func(i, j int) bool { return s[i].InstanceID < s[j].InstanceID })
return s
}
// EachError calls fn for every removed member that has a non-nil error.
func (ls LeaveGroupResponses) EachError(fn func(l LeaveGroupResponse)) {
for _, l := range ls {
if l.Err != nil {
fn(l)
}
}
}
// Each calls fn for every removed member.
func (ls LeaveGroupResponses) Each(fn func(l LeaveGroupResponse)) {
for _, l := range ls {
fn(l)
}
}
// Error iterates over all removed members and returns the first error
// encountered, if any.
func (ls LeaveGroupResponses) Error() error {
for _, l := range ls {
if l.Err != nil {
return l.Err
}
}
return nil
}
// Ok returns true if there are no errors. This is a shortcut for ls.Error() ==
// nil.
func (ls LeaveGroupResponses) Ok() bool {
return ls.Error() == nil
}
// LeaveGroup causes instance IDs to leave a group.
//
// This function allows manually removing members using instance IDs from a
// group, which allows for fast scale down / host replacement (see KIP-345 for
// more detail). This returns an *AuthErr if the use is not authorized to
// remove members from groups.
func (cl *Client) LeaveGroup(ctx context.Context, b *LeaveGroupBuilder) (LeaveGroupResponses, error) {
if b == nil || len(b.instanceIDs) == 0 {
return nil, nil
}
req := kmsg.NewPtrLeaveGroupRequest()
req.Group = b.group
for _, id := range b.instanceIDs {
m := kmsg.NewLeaveGroupRequestMember()
m.InstanceID = id
m.Reason = b.reason
req.Members = append(req.Members, m)
}
resp, err := req.RequestWith(ctx, cl.cl)
if err != nil {
return nil, err
}
if err := maybeAuthErr(resp.ErrorCode); err != nil {
return nil, err
}
if err := kerr.ErrorForCode(resp.ErrorCode); err != nil {
return nil, err
}
resps := make(LeaveGroupResponses)
for _, m := range resp.Members {
if m.InstanceID == nil {
continue // highly unexpected, buggy kafka
}
resps[*m.InstanceID] = LeaveGroupResponse{
Group: b.group,
MemberID: m.MemberID,
InstanceID: *m.InstanceID,
Err: kerr.ErrorForCode(resp.ErrorCode),
}
}
return resps, err
}
// OffsetResponse contains the response for an individual offset for offset
// methods.
type OffsetResponse struct {
Offset
Err error // Err is non-nil if the offset operation failed.
}
// OffsetResponses contains per-partition responses to offset methods.
type OffsetResponses map[string]map[int32]OffsetResponse
// Lookup returns the offset at t and p and whether it exists.
func (os OffsetResponses) Lookup(t string, p int32) (OffsetResponse, bool) {
if len(os) == 0 {
return OffsetResponse{}, false
}
ps := os[t]
if len(ps) == 0 {
return OffsetResponse{}, false
}
o, exists := ps[p]
return o, exists
}
// Keep filters the responses to only keep the input offsets.
func (os OffsetResponses) Keep(o Offsets) {
os.DeleteFunc(func(r OffsetResponse) bool {
if len(o) == 0 {
return true // keep nothing, delete
}
ot := o[r.Topic]
if ot == nil {
return true // topic missing, delete
}
_, ok := ot[r.Partition]
return !ok // does not exist, delete
})
}
// Offsets returns these offset responses as offsets.
func (os OffsetResponses) Offsets() Offsets {
i := make(Offsets)
os.Each(func(o OffsetResponse) {
i.Add(o.Offset)
})
return i
}
// KOffsets returns these offset responses as a kgo offset map.
func (os OffsetResponses) KOffsets() map[string]map[int32]kgo.Offset {
return os.Offsets().KOffsets()
}
// KeepFunc keeps only the offsets for which fn returns true.
func (os OffsetResponses) KeepFunc(fn func(OffsetResponse) bool) {
for t, ps := range os {
for p, o := range ps {
if !fn(o) {
delete(ps, p)
}
}
if len(ps) == 0 {
delete(os, t)
}
}
}
// DeleteFunc deletes any offset for which fn returns true.
func (os OffsetResponses) DeleteFunc(fn func(OffsetResponse) bool) {
os.KeepFunc(func(o OffsetResponse) bool { return !fn(o) })
}
// Add adds an offset for a given topic/partition to this OffsetResponses map
// (even if it exists).
func (os *OffsetResponses) Add(o OffsetResponse) {
if *os == nil {
*os = make(map[string]map[int32]OffsetResponse)
}
ot := (*os)[o.Topic]
if ot == nil {
ot = make(map[int32]OffsetResponse)
(*os)[o.Topic] = ot
}
ot[o.Partition] = o
}
// EachError calls fn for every offset that as a non-nil error.
func (os OffsetResponses) EachError(fn func(o OffsetResponse)) {
for _, ps := range os {
for _, o := range ps {
if o.Err != nil {
fn(o)
}
}
}
}
// Sorted returns the responses sorted by topic and partition.
func (os OffsetResponses) Sorted() []OffsetResponse {
var s []OffsetResponse
os.Each(func(o OffsetResponse) { s = append(s, o) })
sort.Slice(s, func(i, j int) bool {
return s[i].Topic < s[j].Topic ||
s[i].Topic == s[j].Topic && s[i].Partition < s[j].Partition
})
return s
}
// Each calls fn for every offset.
func (os OffsetResponses) Each(fn func(OffsetResponse)) {
for _, ps := range os {
for _, o := range ps {
fn(o)
}
}
}
// Partitions returns the set of unique topics and partitions in these offsets.
func (os OffsetResponses) Partitions() TopicsSet {
s := make(TopicsSet)
os.Each(func(o OffsetResponse) {
s.Add(o.Topic, o.Partition)
})
return s
}
// Error iterates over all offsets and returns the first error encountered, if
// any. This can be used to check if an operation was entirely successful or
// not.
//
// Note that offset operations can be partially successful. For example, some
// offsets could succeed in an offset commit while others fail (maybe one topic
// does not exist for some reason, or you are not authorized for one topic). If
// this is something you need to worry about, you may need to check all offsets
// manually.
func (os OffsetResponses) Error() error {
for _, ps := range os {
for _, o := range ps {
if o.Err != nil {
return o.Err
}
}
}
return nil
}
// Ok returns true if there are no errors. This is a shortcut for os.Error() ==
// nil.
func (os OffsetResponses) Ok() bool {
return os.Error() == nil
}
// CommitOffsets issues an offset commit request for the input offsets.
//
// This function can be used to manually commit offsets when directly consuming
// partitions outside of an actual consumer group. For example, if you assign
// partitions manually, but want still use Kafka to checkpoint what you have
// consumed, you can manually issue an offset commit request with this method.
//
// This does not return on authorization failures, instead, authorization
// failures are included in the responses.
func (cl *Client) CommitOffsets(ctx context.Context, group string, os Offsets) (OffsetResponses, error) {
req := kmsg.NewPtrOffsetCommitRequest()
req.Group = group
for t, ps := range os {
rt := kmsg.NewOffsetCommitRequestTopic()
rt.Topic = t
for p, o := range ps {
rp := kmsg.NewOffsetCommitRequestTopicPartition()
rp.Partition = p
rp.Offset = o.At
rp.LeaderEpoch = o.LeaderEpoch
if len(o.Metadata) > 0 {
rp.Metadata = kmsg.StringPtr(o.Metadata)
}
rt.Partitions = append(rt.Partitions, rp)
}
req.Topics = append(req.Topics, rt)
}
resp, err := req.RequestWith(ctx, cl.cl)
if err != nil {
return nil, err
}
rs := make(OffsetResponses)
for _, t := range resp.Topics {
rt := make(map[int32]OffsetResponse)
rs[t.Topic] = rt
for _, p := range t.Partitions {
rt[p.Partition] = OffsetResponse{
Offset: os[t.Topic][p.Partition],
Err: kerr.ErrorForCode(p.ErrorCode),
}
}
}
for t, ps := range os {
respt := rs[t]
if respt == nil {
respt = make(map[int32]OffsetResponse)
rs[t] = respt
}
for p, o := range ps {
if _, exists := respt[p]; exists {
continue
}
respt[p] = OffsetResponse{
Offset: o,
Err: errOffsetCommitMissing,
}
}
}
return rs, nil
}
var errOffsetCommitMissing = errors.New("partition missing in commit response")
// CommitAllOffsets is identical to CommitOffsets, but returns an error if the
// offset commit was successful, but some offset within the commit failed to be
// committed.
//
// This is a shortcut function provided to avoid checking two errors, but you
// must be careful with this if partially successful commits can be a problem
// for you.
func (cl *Client) CommitAllOffsets(ctx context.Context, group string, os Offsets) error {
commits, err := cl.CommitOffsets(ctx, group, os)
if err != nil {
return err
}
return commits.Error()
}
// FetchOffsets issues an offset fetch requests for all topics and partitions
// in the group. Because Kafka returns only partitions you are authorized to
// fetch, this only returns an auth error if you are not authorized to describe
// the group at all.
//
// This method requires talking to Kafka v0.11+.
func (cl *Client) FetchOffsets(ctx context.Context, group string) (OffsetResponses, error) {
req := kmsg.NewPtrOffsetFetchRequest()
req.Group = group
resp, err := req.RequestWith(ctx, cl.cl)
if err != nil {
return nil, err
}
if err := maybeAuthErr(resp.ErrorCode); err != nil {
return nil, err
}
if err := kerr.ErrorForCode(resp.ErrorCode); err != nil {
return nil, err
}
rs := make(OffsetResponses)
for _, t := range resp.Topics {
rt := make(map[int32]OffsetResponse)
rs[t.Topic] = rt
for _, p := range t.Partitions {
if err := maybeAuthErr(p.ErrorCode); err != nil {
return nil, err
}
var meta string
if p.Metadata != nil {
meta = *p.Metadata
}
rt[p.Partition] = OffsetResponse{
Offset: Offset{
Topic: t.Topic,
Partition: p.Partition,
At: p.Offset,
LeaderEpoch: p.LeaderEpoch,
Metadata: meta,
},
Err: kerr.ErrorForCode(p.ErrorCode),
}
}
}
return rs, nil
}
// FetchAllGroupTopics is a kadm "internal" topic name that can be used in
// [FetchOffsetsForTopics]. By default, [FetchOffsetsForTopics] only returns
// topics that are explicitly requested. Other topics that may be committed to
// in the group are not returned. Using FetchAllRequestedTopics switches the
// behavior to return the union of all committed topics and all requested
// topics.
const FetchAllGroupTopics = "|fetch-all-group-topics|"
// FetchOffsetsForTopics is a helper function that returns the currently
// committed offsets for the given group, as well as default -1 offsets for any
// topic/partition that does not yet have a commit.
//
// If any partition fetched or listed has an error, this function returns an
// error. The returned offset responses are ready to be used or converted
// directly to pure offsets with `Into`, and again into kgo offsets with
// another `Into`.
//
// By default, this function returns offsets for only the requested topics. You
// can use the special "topic" [FetchAllGroupTopics] to return all committed-to
// topics in addition to all requested topics.
func (cl *Client) FetchOffsetsForTopics(ctx context.Context, group string, topics ...string) (OffsetResponses, error) {
os := make(Offsets)
var all bool
keept := topics[:0]
for _, topic := range topics {
if topic == FetchAllGroupTopics {
all = true
continue
}
keept = append(keept, topic)
}
topics = keept
if !all && len(topics) == 0 {
return make(OffsetResponses), nil
}
// We have to request metadata to learn all partitions in all the
// topics. The default returned offset for all partitions is filled in
// to be -1.
if len(topics) > 0 {
listed, err := cl.ListTopics(ctx, topics...)
if err != nil {
return nil, fmt.Errorf("unable to list topics: %w", err)
}
for _, topic := range topics {
t := listed[topic]
if t.Err != nil {
return nil, fmt.Errorf("unable to describe topics, topic err: %w", t.Err)
}
for _, p := range t.Partitions {
os.AddOffset(topic, p.Partition, -1, -1)
}
}
}
resps, err := cl.FetchOffsets(ctx, group)
if err != nil {
return nil, fmt.Errorf("unable to fetch offsets: %w", err)
}
if err := resps.Error(); err != nil {
return nil, fmt.Errorf("offset fetches had a load error, first error: %w", err)
}
// For any topic (and any partition) we explicitly asked for, if the
// partition does not exist in the response, we fill the default -1
// from above.
os.Each(func(o Offset) {
if _, ok := resps.Lookup(o.Topic, o.Partition); !ok {
resps.Add(OffsetResponse{Offset: o})
}
})
// If we are not requesting all group offsets, then we strip any topic
// that was not explicitly requested.
if !all {
tset := make(map[string]struct{})
for _, t := range topics {
tset[t] = struct{}{}
}
for t := range resps {
if _, ok := tset[t]; !ok {
delete(resps, t)
}
}
}
return resps, nil
}
// FetchOffsetsResponse contains a fetch offsets response for a single group.
type FetchOffsetsResponse struct {
Group string // Group is the offsets these fetches correspond to.
Fetched OffsetResponses // Fetched contains offsets fetched for this group, if any.
Err error // Err contains any error preventing offsets from being fetched.
}
// CommittedPartitions returns the set of unique topics and partitions that
// have been committed to in this group.
func (r FetchOffsetsResponse) CommittedPartitions() TopicsSet {
return r.Fetched.Partitions()
}
// FetchOffsetsResponses contains responses for many fetch offsets requests.
type FetchOffsetsResponses map[string]FetchOffsetsResponse
// EachError calls fn for every response that as a non-nil error.
func (rs FetchOffsetsResponses) EachError(fn func(FetchOffsetsResponse)) {
for _, r := range rs {
if r.Err != nil {
fn(r)
}
}
}
// AllFailed returns whether all fetch offsets requests failed.
func (rs FetchOffsetsResponses) AllFailed() bool {
var n int
rs.EachError(func(FetchOffsetsResponse) { n++ })
return len(rs) > 0 && n == len(rs)
}
// CommittedPartitions returns the set of unique topics and partitions that
// have been committed to across all members in all responses. This is the
// all-group analogue to FetchOffsetsResponse.CommittedPartitions.
func (rs FetchOffsetsResponses) CommittedPartitions() TopicsSet {
s := make(TopicsSet)
for _, r := range rs {
s.Merge(r.CommittedPartitions())
}
return s
}
// On calls fn for the response group if it exists, returning the response and
// the error returned from fn. If fn is nil, this simply returns the group.
//
// The fn is given a copy of the response. This function returns the copy as
// well; any modifications within fn are modifications on the returned copy.
//
// If the group does not exist, this returns kerr.GroupIDNotFound.
func (rs FetchOffsetsResponses) On(group string, fn func(*FetchOffsetsResponse) error) (FetchOffsetsResponse, error) {
if len(rs) > 0 {
r, ok := rs[group]
if ok {
if fn == nil {
return r, nil
}
return r, fn(&r)
}
}
return FetchOffsetsResponse{}, kerr.GroupIDNotFound
}
// Error iterates over all responses and returns the first error encountered,
// if any.
func (rs FetchOffsetsResponses) Error() error {
for _, r := range rs {
if r.Err != nil {
return r.Err
}
}
return nil
}
// FetchManyOffsets issues a fetch offsets requests for each group specified.
//
// This function is a batch version of FetchOffsets. FetchOffsets and
// CommitOffsets are important to provide as simple APIs for users that manage
// group offsets outside of a consumer group. Each individual group may have an
// auth error.
func (cl *Client) FetchManyOffsets(ctx context.Context, groups ...string) FetchOffsetsResponses {
fetched := make(FetchOffsetsResponses)
if len(groups) == 0 {
return fetched
}
req := kmsg.NewPtrOffsetFetchRequest()
for _, group := range groups {
rg := kmsg.NewOffsetFetchRequestGroup()
rg.Group = group
req.Groups = append(req.Groups, rg)
}
groupErr := func(g string, err error) {
fetched[g] = FetchOffsetsResponse{
Group: g,
Err: err,
}
}
allGroupsErr := func(req *kmsg.OffsetFetchRequest, err error) {
for _, g := range req.Groups {
groupErr(g.Group, err)
}
}
shards := cl.cl.RequestSharded(ctx, req)
for _, shard := range shards {
req := shard.Req.(*kmsg.OffsetFetchRequest)
if shard.Err != nil {
allGroupsErr(req, shard.Err)
continue
}
resp := shard.Resp.(*kmsg.OffsetFetchResponse)
if err := maybeAuthErr(resp.ErrorCode); err != nil {
allGroupsErr(req, err)
continue
}
for _, g := range resp.Groups {
if err := maybeAuthErr(g.ErrorCode); err != nil {
groupErr(g.Group, err)
continue
}
rs := make(OffsetResponses)
fg := FetchOffsetsResponse{
Group: g.Group,
Fetched: rs,
Err: kerr.ErrorForCode(g.ErrorCode),
}
fetched[g.Group] = fg // group coordinator owns all of a group, no need to check existence
for _, t := range g.Topics {
rt := make(map[int32]OffsetResponse)
rs[t.Topic] = rt
for _, p := range t.Partitions {
var meta string
if p.Metadata != nil {
meta = *p.Metadata
}
rt[p.Partition] = OffsetResponse{
Offset: Offset{
Topic: t.Topic,
Partition: p.Partition,
At: p.Offset,
LeaderEpoch: p.LeaderEpoch,
Metadata: meta,
},
Err: kerr.ErrorForCode(p.ErrorCode),
}
}
}
}
}
return fetched
}
// DeleteOffsetsResponses contains the per topic, per partition errors. If an
// offset deletion for a partition was successful, the error will be nil.
type DeleteOffsetsResponses map[string]map[int32]error
// Lookup returns the response at t and p and whether it exists.
func (ds DeleteOffsetsResponses) Lookup(t string, p int32) (error, bool) { //nolint:revive // error comes first, it is what it is
if len(ds) == 0 {
return nil, false
}
ps := ds[t]
if len(ps) == 0 {
return nil, false
}
r, exists := ps[p]
return r, exists
}
// EachError calls fn for every partition that as a non-nil deletion error.
func (ds DeleteOffsetsResponses) EachError(fn func(string, int32, error)) {
for t, ps := range ds {
for p, err := range ps {
if err != nil {
fn(t, p, err)
}
}
}
}
// Error iterates over all responses and returns the first error encountered,
// if any.
func (ds DeleteOffsetsResponses) Error() error {
for _, ps := range ds {
for _, err := range ps {
if err != nil {
return err
}
}
}
return nil
}
// DeleteOffsets deletes offsets for the given group.
//
// Originally, offset commits were persisted in Kafka for some retention time.
// This posed problematic for infrequently committing consumers, so the
// retention time concept was removed in Kafka v2.1 in favor of deleting
// offsets for a group only when the group became empty. However, if a group
// stops consuming from a topic, then the offsets will persist and lag
// monitoring for the group will notice an ever increasing amount of lag for
// these no-longer-consumed topics. Thus, Kafka v2.4 introduced an OffsetDelete
// request to allow admins to manually delete offsets for no longer consumed
// topics.
//
// This method requires talking to Kafka v2.4+. This returns an *AuthErr if the
// user is not authorized to delete offsets in the group at all. This does not
// return on per-topic authorization failures, instead, per-topic authorization
// failures are included in the responses.
func (cl *Client) DeleteOffsets(ctx context.Context, group string, s TopicsSet) (DeleteOffsetsResponses, error) {
if len(s) == 0 {
return nil, nil
}
req := kmsg.NewPtrOffsetDeleteRequest()
req.Group = group
for t, ps := range s {
rt := kmsg.NewOffsetDeleteRequestTopic()
rt.Topic = t
for p := range ps {
rp := kmsg.NewOffsetDeleteRequestTopicPartition()
rp.Partition = p
rt.Partitions = append(rt.Partitions, rp)
}
req.Topics = append(req.Topics, rt)
}
resp, err := req.RequestWith(ctx, cl.cl)
if err != nil {
return nil, err
}
if err := maybeAuthErr(resp.ErrorCode); err != nil {
return nil, err
}
if err := kerr.ErrorForCode(resp.ErrorCode); err != nil {
return nil, err
}
r := make(DeleteOffsetsResponses)
for _, t := range resp.Topics {
rt := make(map[int32]error)
r[t.Topic] = rt
for _, p := range t.Partitions {
rt[p.Partition] = kerr.ErrorForCode(p.ErrorCode)
}
}
return r, nil
}
// GroupMemberLag is the lag between a group member's current offset commit and
// the current end offset.
//
// If either the offset commits have load errors, or the listed end offsets
// have load errors, the Lag field will be -1 and the Err field will be set (to
// the first of either the commit error, or else the list error).
//
// If the group is in the Empty state, lag is calculated for all partitions in
// a topic, but the member is nil. The calculate function assumes that any
// assigned topic is meant to be entirely consumed. If the group is Empty and
// topics could not be listed, some partitions may be missing.
type GroupMemberLag struct {
// Member is a reference to the group member consuming this partition.
// If the group is in state Empty, the member will be nil.
Member *DescribedGroupMember
Topic string // Topic is the topic this lag is for.
Partition int32 // Partition is the partition this lag is for.
Commit Offset // Commit is this member's current offset commit.
Start ListedOffset // Start is a reference to the start of this partition, if provided. Start offsets are optional; if not provided, Start.Err is a non-nil error saying this partition is missing from list offsets. This is always present if lag is calculated via Client.Lag.
End ListedOffset // End is a reference to the end offset of this partition.
Lag int64 // Lag is how far behind this member is, or -1 if there is a commit error or list offset error.
Err error // Err is either the commit error, or the list end offsets error, or nil.
}
// IsEmpty returns if the this lag is for a group in the Empty state.
func (g *GroupMemberLag) IsEmpty() bool { return g.Member == nil }
// GroupLag is the per-topic, per-partition lag of members in a group.
type GroupLag map[string]map[int32]GroupMemberLag
// Lookup returns the lag at t and p and whether it exists.
func (l GroupLag) Lookup(t string, p int32) (GroupMemberLag, bool) {
if len(l) == 0 {
return GroupMemberLag{}, false
}
ps := l[t]
if len(ps) == 0 {
return GroupMemberLag{}, false
}
m, exists := ps[p]
return m, exists
}
// Sorted returns the per-topic, per-partition lag by member sorted in order by
// topic then partition.
func (l GroupLag) Sorted() []GroupMemberLag {
var all []GroupMemberLag
for _, ps := range l {
for _, l := range ps {
all = append(all, l)
}
}
sort.Slice(all, func(i, j int) bool {
l, r := all[i], all[j]
if l.Topic < r.Topic {
return true
}
if l.Topic > r.Topic {
return false
}
return l.Partition < r.Partition
})
return all
}
// IsEmpty returns if the group is empty.
func (l GroupLag) IsEmpty() bool {
for _, ps := range l {
for _, m := range ps {
return m.IsEmpty()
}
}
return false
}
// Total returns the total lag across all topics.
func (l GroupLag) Total() int64 {
var tot int64
for _, tl := range l.TotalByTopic() {
tot += tl.Lag
}
return tot
}
// TotalByTopic returns the total lag for each topic.
func (l GroupLag) TotalByTopic() GroupTopicsLag {
m := make(map[string]TopicLag)
for t, ps := range l {
mt := TopicLag{
Topic: t,
}
for _, l := range ps {
if l.Lag > 0 {
mt.Lag += l.Lag
}
}
m[t] = mt
}
return m
}
// GroupTopicsLag is the total lag per topic within a group.
type GroupTopicsLag map[string]TopicLag
// TopicLag is the lag for an individual topic within a group.
type TopicLag struct {
Topic string
Lag int64
}
// Sorted returns the per-topic lag, sorted by topic.
func (l GroupTopicsLag) Sorted() []TopicLag {
var all []TopicLag
for _, tl := range l {
all = append(all, tl)
}
sort.Slice(all, func(i, j int) bool {
return all[i].Topic < all[j].Topic
})
return all
}
// DescribedGroupLag contains a described group and its lag, or the errors that
// prevent the lag from being calculated.
type DescribedGroupLag struct {
Group string // Group is the group name.
Coordinator BrokerDetail // Coordinator is the coordinator broker for this group.
State string // State is the state this group is in (Empty, Dead, Stable, etc.).
ProtocolType string // ProtocolType is the type of protocol the group is using, "consumer" for normal consumers, "connect" for Kafka connect.
Protocol string // Protocol is the partition assignor strategy this group is using.
Members []DescribedGroupMember // Members contains the members of this group sorted first by InstanceID, or if nil, by MemberID.
Lag GroupLag // Lag is the lag for the group.
DescribeErr error // DescribeErr is the error returned from describing the group, if any.
FetchErr error // FetchErr is the error returned from fetching offsets, if any.
}
// Error returns the first of DescribeErr or FetchErr that is non-nil.
func (l *DescribedGroupLag) Error() error {
if l.DescribeErr != nil {
return l.DescribeErr
}
return l.FetchErr
}
// DescribedGroupLags is a map of group names to the described group with its
// lag, or error for those groups.
type DescribedGroupLags map[string]DescribedGroupLag
// Sorted returns all lags sorted by group name.
func (ls DescribedGroupLags) Sorted() []DescribedGroupLag {
s := make([]DescribedGroupLag, 0, len(ls))
for _, l := range ls {
s = append(s, l)
}
sort.Slice(s, func(i, j int) bool { return s[i].Group < s[j].Group })
return s
}
// EachError calls fn for every group that has a non-nil error.
func (ls DescribedGroupLags) EachError(fn func(l DescribedGroupLag)) {
for _, l := range ls {
if l.Error() != nil {
fn(l)
}
}
}
// Each calls fn for every group.
func (ls DescribedGroupLags) Each(fn func(l DescribedGroupLag)) {
for _, l := range ls {
fn(l)
}
}
// Error iterates over all groups and returns the first error encountered, if
// any.
func (ls DescribedGroupLags) Error() error {
for _, l := range ls {
if l.Error() != nil {
return l.Error()
}
}
return nil
}
// Ok returns true if there are no errors. This is a shortcut for ls.Error() ==
// nil.
func (ls DescribedGroupLags) Ok() bool {
return ls.Error() == nil
}
// Lag returns the lag for all input groups. This function is a shortcut for
// the steps required to use CalculateGroupLagWithStartOffsets properly, with
// some opinionated choices for error handling since calculating lag is
// multi-request process. If a group cannot be described or the offsets cannot
// be fetched, an error is returned for the group. If any topic cannot have its
// end offsets listed, the lag for the partition has a corresponding error. If
// any request fails with an auth error, this returns *AuthError.
func (cl *Client) Lag(ctx context.Context, groups ...string) (DescribedGroupLags, error) {
set := make(map[string]struct{}, len(groups))
for _, g := range groups {
set[g] = struct{}{}
}
rem := func() []string {
groups = groups[:0]
for g := range set {
groups = append(groups, g)
}
return groups
}
lags := make(DescribedGroupLags)
described, err := cl.DescribeGroups(ctx, rem()...)
// For auth err: always return.
// For shard errors, if we had some partial success, then we continue
// to the rest of the logic in this function.
// If every shard failed, or on all other errors, we return.
var ae *AuthError
var se *ShardErrors
switch {
case errors.As(err, &ae):
return nil, err
case errors.As(err, &se) && !se.AllFailed:
for _, se := range se.Errs {
// can be ListGroupsRequest as well
req, ok := se.Req.(*kmsg.DescribeGroupsRequest)
if !ok {
continue
}
for _, g := range req.Groups {
lags[g] = DescribedGroupLag{
Group: g,
Coordinator: se.Broker,
DescribeErr: se.Err,
}
delete(set, g)
}
}
case err != nil:
return nil, err
}
for _, g := range described {
lags[g.Group] = DescribedGroupLag{
Group: g.Group,
Coordinator: g.Coordinator,
State: g.State,
ProtocolType: g.ProtocolType,
Protocol: g.Protocol,
Members: g.Members,
DescribeErr: g.Err,
}
if g.Err != nil {
delete(set, g.Group)
continue
}
// If the input set of groups is empty, DescribeGroups returns all groups.
// We add to `set` here so that the Lag function itself can calculate
// lag for all groups.
set[g.Group] = struct{}{}
}
if len(set) == 0 {
return lags, nil
}
// Same thought here. For auth errors, we always return.
// If a group offset fetch failed, we delete it from described
// because we cannot calculate lag for it.
fetched := cl.FetchManyOffsets(ctx, rem()...)
for _, r := range fetched {
switch {
case errors.As(r.Err, &ae):
return nil, err
case r.Err != nil:
l := lags[r.Group]
l.FetchErr = r.Err
lags[r.Group] = l
delete(set, r.Group)
delete(described, r.Group)
}
}
if len(set) == 0 {
return lags, nil
}
// We have to list the start & end offset for all assigned and
// committed partitions, as well as any topics that group members
// WANT to consume (maybe they have not yet been committed to and
// we are currently eagerly rebalancing).
var startOffsets, endOffsets ListedOffsets
listPartitions := described.AssignedPartitions()
listPartitions.MergeTopics(described.JoinTopics())
listPartitions.Merge(fetched.CommittedPartitions())
if topics := listPartitions.Topics(); len(topics) > 0 {
for _, list := range []struct {
fn func(context.Context, ...string) (ListedOffsets, error)
dst *ListedOffsets
}{
{cl.ListStartOffsets, &startOffsets},
{cl.ListEndOffsets, &endOffsets},
} {
listed, err := list.fn(ctx, topics...)
*list.dst = listed
// As above: return on auth error. If there are shard errors,
// the topics will be missing in the response and then
// CalculateGroupLag will return UnknownTopicOrPartition.
switch {
case errors.As(err, &ae):
return nil, err
case errors.As(err, &se):
// do nothing: these show up as errListMissing
case err != nil:
return nil, err
}
// For anything that lists with a single -1 partition, the
// topic does not exist. We add an UnknownTopicOrPartition
// error for all partitions that were committed to, so that
// this shows up in the lag output as UnknownTopicOrPartition
// rather than errListMissing.
for t, ps := range listed {
if len(ps) != 1 {
continue
}
if _, ok := ps[-1]; !ok {
continue
}
delete(ps, -1)
for p := range listPartitions[t] {
ps[p] = ListedOffset{
Topic: t,
Partition: p,
Err: kerr.UnknownTopicOrPartition,
}
}
}
}
}
for _, g := range described {
l := lags[g.Group]
l.Lag = CalculateGroupLagWithStartOffsets(g, fetched[g.Group].Fetched, startOffsets, endOffsets)
lags[g.Group] = l
}
return lags, nil
}
// CalculateGroupLag returns the per-partition lag of all members in a group.
// The input to this method is the returns from the following methods (make
// sure to check shard errors):
//
// // Note that FetchOffsets exists to fetch only one group's offsets,
// // but some of the code below slightly changes.
// groups := DescribeGroups(ctx, group)
// commits := FetchManyOffsets(ctx, group)
// var endOffsets ListedOffsets
// listPartitions := described.AssignedPartitions()
// listPartitions.MergeTopics(described.JoinTopics())
// listPartitions.Merge(commits.CommittedPartitions()
// if topics := listPartitions.Topics(); len(topics) > 0 {
// endOffsets = ListEndOffsets(ctx, listPartitions.Topics())
// }
// for _, group := range groups {
// lag := CalculateGroupLag(group, commits[group.Group].Fetched, endOffsets)
// }
//
// If assigned partitions are missing in the listed end offsets, the partition
// will have an error indicating it is missing. A missing topic or partition in
// the commits is assumed to be nothing committing yet.
func CalculateGroupLag(
group DescribedGroup,
commit OffsetResponses,
endOffsets ListedOffsets,
) GroupLag {
return CalculateGroupLagWithStartOffsets(group, commit, nil, endOffsets)
}
var noOffsets = make(ListedOffsets)
// CalculateGroupLagWithStartOffsets returns the per-partition lag of all
// members in a group. This function slightly expands on [CalculateGroupLag] to
// handle calculating lag for partitions that (1) have no commits AND (2) have
// some segments deleted (cleanup.policy=delete) such that the log start offset
// is non-zero.
//
// As an example, if a group is consuming a partition with log end offset 30
// and log start offset 10 and has not yet committed to the group, this
// function can correctly tell you that the lag is 20, whereas
// CalculateGroupLag would tell you the lag is 30.
//
// This function accepts 'nil' for startOffsets, which will result in the same
// behavior as CalculateGroupLag. This function is useful if you have
// infrequently committing groups against topics that have segments being
// deleted.
func CalculateGroupLagWithStartOffsets(
group DescribedGroup,
commit OffsetResponses,
startOffsets ListedOffsets,
endOffsets ListedOffsets,
) GroupLag {
if commit == nil { // avoid panics below
commit = make(OffsetResponses)
}
if startOffsets == nil {
startOffsets = noOffsets
}
if endOffsets == nil {
endOffsets = noOffsets
}
l := make(map[string]map[int32]GroupMemberLag)
for mi, m := range group.Members {
c, ok := m.Assigned.AsConsumer()
if !ok {
continue
}
for _, t := range c.Topics {
lt := l[t.Topic]
if lt == nil {
lt = make(map[int32]GroupMemberLag)
l[t.Topic] = lt
}
tcommit := commit[t.Topic]
tstart := startOffsets[t.Topic]
tend := endOffsets[t.Topic]
for _, p := range t.Partitions {
var (
pcommit = OffsetResponse{Offset: Offset{
Topic: t.Topic,
Partition: p,
At: -1,
}}
pend = ListedOffset{
Topic: t.Topic,
Partition: p,
Err: errListMissing,
}
pstart = pend
perr error
)
if tcommit != nil {
if pcommitActual, ok := tcommit[p]; ok {
pcommit = pcommitActual
}
}
// In order of priority, perr (the error on the Lag
// calculation) is non-nil if:
//
// * The topic is missing from end ListOffsets
// * The partition is missing from end ListOffsets
// * OffsetFetch has an error on the partition
// * ListOffsets has an error on the partition
//
// If we have no error, then we can calculate lag.
// We *do* allow an error on start ListedOffsets;
// if there are no start offsets or the start offset
// has an error, it is not used for lag calculation.
perr = errListMissing
if tend != nil {
if pendActual, ok := tend[p]; ok {
pend = pendActual
perr = nil
}
}
if perr == nil {
if perr = pcommit.Err; perr == nil {
perr = pend.Err
}
}
if tstart != nil {
if pstartActual, ok := tstart[p]; ok {
pstart = pstartActual
}
}
lag := int64(-1)
if perr == nil {
lag = pend.Offset
if pstart.Err == nil {
lag = pend.Offset - pstart.Offset
}
if pcommit.At >= 0 {
lag = pend.Offset - pcommit.At
}
// It is possible for a commit to be after the
// end, in which case we will round to 0. We do
// this check here to also handle a potential non-commit
// weird pend < pstart scenario where a segment
// was deleted between listing offsets.
if lag < 0 {
lag = 0
}
}
lt[p] = GroupMemberLag{
Member: &group.Members[mi],
Topic: t.Topic,
Partition: p,
Commit: pcommit.Offset,
Start: pstart,
End: pend,
Lag: lag,
Err: perr,
}
}
}
// For all members, we prime the lag map with the topics the
// member is interested in. It is possible the group is
// rebalancing (no partitions assigned!) and that topics have
// not been committed to.
j, ok := m.Join.AsConsumer()
if !ok {
continue
}
for _, t := range j.Topics {
if _, ok := l[t]; !ok {
l[t] = make(map[int32]GroupMemberLag)
}
}
}
// Now we go through everything previously committed to and calculate
// the lag for any topic or partition that is not currently assigned
// to any member. This block also entirely handles the case when the
// group.State is "Empty".
for t, ps := range commit {
lt := l[t]
if lt == nil {
lt = make(map[int32]GroupMemberLag)
l[t] = lt
}
var tstart, tend map[int32]ListedOffset
var tlookup bool
for p, pcommit := range ps {
if _, ok := lt[p]; ok {
continue
}
if !tlookup {
tstart = startOffsets[t]
tend = endOffsets[t]
tlookup = true
}
var (
pend = ListedOffset{
Topic: t,
Partition: p,
Err: errListMissing,
}
pstart = pend
perr error
)
// The following block duplicates the above;
// the difference here is we have no member.
perr = errListMissing
if tend != nil {
if pendActual, ok := tend[p]; ok {
pend = pendActual
perr = nil
}
}
if perr == nil {
if perr = pcommit.Err; perr == nil {
perr = pend.Err
}
}
if tstart != nil {
if pstartActual, ok := tstart[p]; ok {
pstart = pstartActual
}
}
lag := int64(-1)
if perr == nil {
lag = pend.Offset
if pstart.Err == nil {
lag = pend.Offset - pstart.Offset
}
if pcommit.At >= 0 {
lag = pend.Offset - pcommit.At
}
if lag < 0 {
lag = 0
}
}
lt[p] = GroupMemberLag{
Topic: t,
Partition: p,
Commit: pcommit.Offset,
Start: pstart,
End: pend,
Lag: lag,
Err: perr,
}
}
}
// Now we look at all topics that we calculated lag for, and check out
// the partitions we listed. Any partition that does not have lag
// calculated yet was never committed to and is not currently assigned;
// it is entirely lagging.
for t, lt := range l {
var tstart map[int32]ListedOffset
for p, pend := range endOffsets[t] {
if _, ok := lt[p]; ok {
continue
}
if tstart == nil {
tstart = startOffsets[t]
}
pcommit := Offset{
Topic: t,
Partition: p,
At: -1,
LeaderEpoch: -1,
}
perr := pend.Err
lag := int64(-1)
if perr == nil {
lag = pend.Offset
}
pstart := ListedOffset{
Topic: t,
Partition: p,
Err: errListMissing,
}
if tstart != nil {
if pstartActual, ok := tstart[p]; ok {
pstart = pstartActual
if pstart.Err == nil {
lag = pend.Offset - pstart.Offset
if lag < 0 {
lag = 0
}
}
}
}
lt[p] = GroupMemberLag{
Topic: t,
Partition: p,
Commit: pcommit,
Start: pstart,
End: pend,
Lag: lag,
Err: perr,
}
}
}
return l
}
var errListMissing = errors.New("missing from list offsets")
////////////////
// "NEXT" GEN //
////////////////
// ConsumerGroupMember is the detail of an individual consumer group member as returned
// by a consumer group describe response (KIP-848).
type ConsumerGroupMember struct {
MemberID string // MemberID is the member ID of this group member.
InstanceID *string // InstanceID is a potential user assigned instance ID of this group member.
RackID *string // RackID is the rack ID of this member, if any.
MemberEpoch int32 // MemberEpoch is the current member epoch.
ClientID string // ClientID is the client ID used by this member.
ClientHost string // ClientHost is the host this member is running on.
MemberType int8 // MemberType is the member type: -1 for unknown, 0 for classic, 1 for consumer (v1+).
SubscribedTopics []string // SubscribedTopics are the topic names this member is subscribed to.
SubscribedTopicRegex *string // SubscribedTopicRegex is the topic regex this member is subscribed to, if any.
Assignment TopicsSet // Assignment is the current assignment for this member.
TargetAssignment TopicsSet // TargetAssignment is the target assignment for this member.
}
// DescribedConsumerGroup contains data from a consumer group describe response for a single
// group (KIP-848).
type DescribedConsumerGroup struct {
Group string // Group is the name of the described group.
Coordinator BrokerDetail // Coordinator is the coordinator broker for this group.
State string // State is the state this group is in.
Epoch int32 // Epoch is the group epoch.
AssignmentEpoch int32 // AssignmentEpoch is the assignment epoch.
AssignorName string // AssignorName is the selected assignor for this group.
Members []ConsumerGroupMember // Members contains the members of this group sorted first by InstanceID, or if nil, by MemberID.
AuthorizedOperations []ACLOperation // AuthorizedOperations contains operations the requesting client is allowed to perform on this group.
Err error // Err is non-nil if the group could not be described.
}
// DescribedConsumerGroups contains data for multiple consumer groups from a consumer group
// describe response.
type DescribedConsumerGroups map[string]DescribedConsumerGroup
// AssignedPartitions returns the set of unique topics and partitions that are
// assigned across all members in this group.
func (d *DescribedConsumerGroup) AssignedPartitions() TopicsSet {
s := make(TopicsSet)
for _, m := range d.Members {
s.Merge(m.Assignment)
}
return s
}
// TargetPartitions returns the set of unique topics and partitions that are
// targeted for assignment across all members in this group.
func (d *DescribedConsumerGroup) TargetPartitions() TopicsSet {
s := make(TopicsSet)
for _, m := range d.Members {
s.Merge(m.TargetAssignment)
}
return s
}
// SubscribedTopics returns the set of topics that all members are interested in
// consuming.
func (d DescribedConsumerGroup) SubscribedTopics() []string {
s := make(map[string]struct{})
for _, m := range d.Members {
for _, t := range m.SubscribedTopics {
s[t] = struct{}{}
}
}
ks := make([]string, 0, len(s))
for k := range s {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// AssignedPartitions returns the set of unique topics and partitions that are
// assigned across all members in all groups. This is the all-group analogue to
// DescribedConsumerGroup.AssignedPartitions.
func (ds DescribedConsumerGroups) AssignedPartitions() TopicsSet {
s := make(TopicsSet)
for _, g := range ds {
for _, m := range g.Members {
s.Merge(m.Assignment)
}
}
return s
}
// SubscribedTopics returns the set of topics that all members are interested in
// consuming. This is the all-group analogue to DescribedConsumerGroup.SubscribedTopics.
func (ds DescribedConsumerGroups) SubscribedTopics() []string {
s := make(map[string]struct{})
for _, g := range ds {
for _, m := range g.Members {
for _, t := range m.SubscribedTopics {
s[t] = struct{}{}
}
}
}
ks := make([]string, 0, len(s))
for k := range s {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// Sorted returns all groups sorted by group name.
func (ds DescribedConsumerGroups) Sorted() []DescribedConsumerGroup {
s := make([]DescribedConsumerGroup, 0, len(ds))
for _, d := range ds {
s = append(s, d)
}
sort.Slice(s, func(i, j int) bool { return s[i].Group < s[j].Group })
return s
}
// Error iterates over all groups and returns the first error encountered, if
// any.
func (ds DescribedConsumerGroups) Error() error {
for _, d := range ds {
if d.Err != nil {
return d.Err
}
}
return nil
}
// Names returns a sorted list of all group names.
func (ds DescribedConsumerGroups) Names() []string {
all := make([]string, 0, len(ds))
for g := range ds {
all = append(all, g)
}
sort.Strings(all)
return all
}
// DescribeConsumerGroups describes either all consumer groups specified, or
// all consumer groups in th ecluster if none are specified. This is the "next
// generation" equivalent of DescribeGroups and is specifically for consumer
// groups using the new consumer group protocol.
//
// This may return *ShardErrors or *AuthError.
//
// If no groups are specified and this method first lists groups, and list
// groups returns a *ShardErrors, this function describes all successfully
// listed groups and appends the list shard errors to any describe shard
// errors.
//
// If only one group is described, there will be at most one request issued,
// and there is no need to deeply inspect the error.
func (cl *Client) DescribeConsumerGroups(ctx context.Context, groups ...string) (DescribedConsumerGroups, error) {
var seList *ShardErrors
if len(groups) == 0 {
listed, err := cl.ListGroupsByType(ctx, []string{"consumer"})
switch {
case err == nil:
case errors.As(err, &seList):
default:
return nil, err
}
groups = listed.Groups()
if len(groups) == 0 {
return nil, err
}
}
req := kmsg.NewPtrConsumerGroupDescribeRequest()
req.Groups = groups
req.IncludeAuthorizedOperations = true
shards := cl.cl.RequestSharded(ctx, req)
described := make(DescribedConsumerGroups)
err := shardErrEachBroker(req, shards, func(b BrokerDetail, kr kmsg.Response) error {
resp := kr.(*kmsg.ConsumerGroupDescribeResponse)
for _, rg := range resp.Groups {
if err := maybeAuthErr(rg.ErrorCode); err != nil {
return err
}
g := DescribedConsumerGroup{
Group: rg.Group,
Coordinator: b,
State: rg.State,
Epoch: rg.Epoch,
AssignmentEpoch: rg.AssignmentEpoch,
AssignorName: rg.AssignorName,
AuthorizedOperations: DecodeACLOperations(rg.AuthorizedOperations),
Err: kerr.ErrorForCode(rg.ErrorCode),
}
for _, rm := range rg.Members {
gm := ConsumerGroupMember{
MemberID: rm.MemberID,
InstanceID: rm.InstanceID,
RackID: rm.RackID,
MemberEpoch: rm.MemberEpoch,
ClientID: rm.ClientID,
ClientHost: rm.ClientHost,
MemberType: rm.MemberType,
SubscribedTopics: rm.SubscribedTopics,
SubscribedTopicRegex: rm.SubscribedTopicRegex,
Assignment: make(TopicsSet),
TargetAssignment: make(TopicsSet),
}
for _, tp := range rm.Assignment.TopicPartitions {
gm.Assignment.Add(tp.Topic, tp.Partitions...)
}
for _, tp := range rm.TargetAssignment.TopicPartitions {
gm.TargetAssignment.Add(tp.Topic, tp.Partitions...)
}
g.Members = append(g.Members, gm)
}
sort.Slice(g.Members, func(i, j int) bool {
if g.Members[i].InstanceID != nil {
if g.Members[j].InstanceID == nil {
return true
}
return *g.Members[i].InstanceID < *g.Members[j].InstanceID
}
if g.Members[j].InstanceID != nil {
return false
}
return g.Members[i].MemberID < g.Members[j].MemberID
})
described[g.Group] = g
}
return nil
})
var seDesc *ShardErrors
switch {
case err == nil:
return described, nil
case errors.As(err, &seDesc):
return described, seDesc.into()
default:
return nil, err
}
}
//////////////////
// SHARE GROUPS //
//////////////////
// ShareGroupMember is the detail of an individual share group member as returned
// by a share group describe response (KIP-932).
type ShareGroupMember struct {
MemberID string // MemberID is the member ID of this group member.
RackID *string // RackID is the rack ID of this member, if any.
MemberEpoch int32 // MemberEpoch is the current member epoch.
ClientID string // ClientID is the client ID used by this member.
ClientHost string // ClientHost is the host this member is running on.
SubscribedTopicNames []string // SubscribedTopicNames are the topic names this member is subscribed to.
Assignment TopicsSet // Assignment is the current assignment for this member.
}
// DescribedShareGroup contains data from a share group describe response for a single
// group (KIP-932).
type DescribedShareGroup struct {
GroupID string // GroupID is the ID of the described group.
Coordinator BrokerDetail // Coordinator is the coordinator broker for this group.
GroupState string // GroupState is the state this group is in.
GroupEpoch int32 // GroupEpoch is the group epoch.
AssignmentEpoch int32 // AssignmentEpoch is the assignment epoch.
Assignor string // Assignor is the selected assignor for this group.
Members []ShareGroupMember // Members contains the members of this group sorted by MemberID.
AuthorizedOperations []ACLOperation // AuthorizedOperations contains operations the requesting client is allowed to perform on this group.
Err error // Err is non-nil if the group could not be described.
}
// DescribedShareGroups contains data for multiple share groups from a share group
// describe response.
type DescribedShareGroups map[string]DescribedShareGroup
// AssignedPartitions returns the set of unique topics and partitions that are
// assigned across all members in this group.
func (d *DescribedShareGroup) AssignedPartitions() TopicsSet {
s := make(TopicsSet)
for _, m := range d.Members {
s.Merge(m.Assignment)
}
return s
}
// SubscribedTopics returns the set of topics that all members are interested in
// consuming.
func (d DescribedShareGroup) SubscribedTopics() []string {
s := make(map[string]struct{})
for _, m := range d.Members {
for _, t := range m.SubscribedTopicNames {
s[t] = struct{}{}
}
}
ks := make([]string, 0, len(s))
for k := range s {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// AssignedPartitions returns the set of unique topics and partitions that are
// assigned across all members in all groups. This is the all-group analogue to
// DescribedShareGroup.AssignedPartitions.
func (ds DescribedShareGroups) AssignedPartitions() TopicsSet {
s := make(TopicsSet)
for _, g := range ds {
for _, m := range g.Members {
s.Merge(m.Assignment)
}
}
return s
}
// SubscribedTopics returns the set of topics that all members are interested in
// consuming. This is the all-group analogue to DescribedShareGroup.SubscribedTopics.
func (ds DescribedShareGroups) SubscribedTopics() []string {
s := make(map[string]struct{})
for _, g := range ds {
for _, m := range g.Members {
for _, t := range m.SubscribedTopicNames {
s[t] = struct{}{}
}
}
}
ks := make([]string, 0, len(s))
for k := range s {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// Sorted returns all groups sorted by group ID.
func (ds DescribedShareGroups) Sorted() []DescribedShareGroup {
s := make([]DescribedShareGroup, 0, len(ds))
for _, d := range ds {
s = append(s, d)
}
sort.Slice(s, func(i, j int) bool { return s[i].GroupID < s[j].GroupID })
return s
}
// Error iterates over all groups and returns the first error encountered, if
// any.
func (ds DescribedShareGroups) Error() error {
for _, d := range ds {
if d.Err != nil {
return d.Err
}
}
return nil
}
// GroupIDs returns a sorted list of all group IDs.
func (ds DescribedShareGroups) GroupIDs() []string {
all := make([]string, 0, len(ds))
for g := range ds {
all = append(all, g)
}
sort.Strings(all)
return all
}
// DescribeShareGroups describes either all share groups specified, or all
// share groups in the cluster if none are specified.
//
// This may return *ShardErrors or *AuthError.
//
// If no groups are specified and this method first lists groups, and list
// groups returns a *ShardErrors, this function describes all successfully
// listed groups and appends the list shard errors to any describe shard
// errors.
//
// If only one group is described, there will be at most one request issued,
// and there is no need to deeply inspect the error.
func (cl *Client) DescribeShareGroups(ctx context.Context, groups ...string) (DescribedShareGroups, error) {
var seList *ShardErrors
if len(groups) == 0 {
listed, err := cl.ListGroupsByType(ctx, []string{"share"})
switch {
case err == nil:
case errors.As(err, &seList):
default:
return nil, err
}
groups = listed.Groups()
if len(groups) == 0 {
return nil, err
}
}
req := kmsg.NewPtrShareGroupDescribeRequest()
req.GroupIDs = groups
req.IncludeAuthorizedOperations = true
shards := cl.cl.RequestSharded(ctx, req)
described := make(DescribedShareGroups)
err := shardErrEachBroker(req, shards, func(b BrokerDetail, kr kmsg.Response) error {
resp := kr.(*kmsg.ShareGroupDescribeResponse)
for _, rg := range resp.Groups {
if err := maybeAuthErr(rg.ErrorCode); err != nil {
return err
}
g := DescribedShareGroup{
GroupID: rg.GroupID,
Coordinator: b,
GroupState: rg.GroupState,
GroupEpoch: rg.GroupEpoch,
AssignmentEpoch: rg.AssignmentEpoch,
Assignor: rg.Assignor,
AuthorizedOperations: DecodeACLOperations(rg.AuthorizedOperations),
Err: kerr.ErrorForCode(rg.ErrorCode),
}
for _, rm := range rg.Members {
gm := ShareGroupMember{
MemberID: rm.MemberID,
RackID: rm.RackID,
MemberEpoch: rm.MemberEpoch,
ClientID: rm.ClientID,
ClientHost: rm.ClientHost,
SubscribedTopicNames: rm.SubscribedTopicNames,
Assignment: make(TopicsSet),
}
for _, tp := range rm.Assignment.TopicPartitions {
gm.Assignment.Add(tp.Topic, tp.Partitions...)
}
g.Members = append(g.Members, gm)
}
sort.Slice(g.Members, func(i, j int) bool {
return g.Members[i].MemberID < g.Members[j].MemberID
})
described[g.GroupID] = g
}
return nil
})
var seDesc *ShardErrors
switch {
case err == nil:
return described, nil
case errors.As(err, &seDesc):
return described, seDesc.into()
default:
return nil, err
}
}