mirror of
https://github.com/hanzoai/traces.git
synced 2026-10-30 18:20:41 +00:00
| datasource | package | from | to | | ---------- | ------------------------ | ------- | ------- | | go | github.com/twmb/franz-go | v1.20.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>
2434 lines
73 KiB
Go
2434 lines
73 KiB
Go
package kgo
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/twmb/franz-go/pkg/kerr"
|
|
"github.com/twmb/franz-go/pkg/kmsg"
|
|
)
|
|
|
|
// Offset is a message offset in a partition.
|
|
type Offset struct {
|
|
at int64
|
|
relative int64
|
|
epoch int32
|
|
|
|
currentEpoch int32 // set by us when mapping offsets to brokers
|
|
|
|
noReset bool
|
|
afterMilli bool
|
|
}
|
|
|
|
// Random negative, only significant within this package.
|
|
const atCommitted = -999
|
|
|
|
// MarshalJSON implements json.Marshaler.
|
|
func (o Offset) MarshalJSON() ([]byte, error) {
|
|
if o.relative == 0 {
|
|
return fmt.Appendf(nil, `{"At":%d,"Epoch":%d,"CurrentEpoch":%d}`, o.at, o.epoch, o.currentEpoch), nil
|
|
}
|
|
return fmt.Appendf(nil, `{"At":%d,"Relative":%d,"Epoch":%d,"CurrentEpoch":%d}`, o.at, o.relative, o.epoch, o.currentEpoch), nil
|
|
}
|
|
|
|
// String returns the offset as a string; the purpose of this is for logs.
|
|
func (o Offset) String() string {
|
|
if o.relative == 0 {
|
|
return fmt.Sprintf("{%d e%d ce%d}", o.at, o.epoch, o.currentEpoch)
|
|
} else if o.relative > 0 {
|
|
return fmt.Sprintf("{%d+%d e%d ce%d}", o.at, o.relative, o.epoch, o.currentEpoch)
|
|
}
|
|
return fmt.Sprintf("{%d-%d e%d ce%d}", o.at, -o.relative, o.epoch, o.currentEpoch)
|
|
}
|
|
|
|
// EpochOffset returns this offset as an EpochOffset, allowing visibility into
|
|
// what this offset actually currently is.
|
|
func (o Offset) EpochOffset() EpochOffset {
|
|
return EpochOffset{
|
|
Epoch: o.epoch,
|
|
Offset: o.at,
|
|
}
|
|
}
|
|
|
|
// NewOffset creates and returns an offset to use in [ConsumePartitions] or
|
|
// [ConsumeResetOffset].
|
|
//
|
|
// The default offset begins at the end.
|
|
func NewOffset() Offset {
|
|
return Offset{
|
|
at: -1,
|
|
epoch: -1,
|
|
}
|
|
}
|
|
|
|
// NoResetOffset returns an offset that can be used as a "none" option for the
|
|
// [ConsumeResetOffset] option. By default, NoResetOffset starts consuming from
|
|
// the beginning of partitions (similar to NewOffset().AtStart()). This can be
|
|
// changed with AtEnd, Relative, etc.
|
|
//
|
|
// Using this offset will make it such that if OffsetOutOfRange is ever
|
|
// encountered while consuming, rather than trying to recover, the client will
|
|
// return the error to the user and enter a fatal state (for the affected
|
|
// partition).
|
|
func NoResetOffset() Offset {
|
|
return Offset{
|
|
at: -1,
|
|
epoch: -1,
|
|
noReset: true,
|
|
}
|
|
}
|
|
|
|
// AfterMilli returns an offset that consumes from the first offset after a
|
|
// given timestamp. This option is *not* compatible with any At options (nor
|
|
// Relative nor WithEpoch); using any of those will clear the special
|
|
// millisecond state.
|
|
//
|
|
// This option can be used to consume at the end of existing partitions, but at
|
|
// the start of any new partitions that are created later:
|
|
//
|
|
// AfterMilli(time.Now().UnixMilli())
|
|
//
|
|
// By default when using this offset, if consuming encounters an
|
|
// OffsetOutOfRange error, consuming will reset to the first offset after this
|
|
// timestamp. You can use NoResetOffset().AfterMilli(...) to instead switch the
|
|
// client to a fatal state (for the affected partition).
|
|
func (o Offset) AfterMilli(millisec int64) Offset {
|
|
o.at = millisec
|
|
o.relative = 0
|
|
o.epoch = -1
|
|
o.afterMilli = true
|
|
return o
|
|
}
|
|
|
|
// AtStart copies 'o' and returns an offset starting at the beginning of a
|
|
// partition.
|
|
func (o Offset) AtStart() Offset {
|
|
o.afterMilli = false
|
|
o.at = -2
|
|
return o
|
|
}
|
|
|
|
// AtEnd copies 'o' and returns an offset starting at the end of a partition.
|
|
// If you want to consume at the end of the topic as it exists right now, but
|
|
// at the beginning of new partitions as they are added to the topic later,
|
|
// check out AfterMilli.
|
|
func (o Offset) AtEnd() Offset {
|
|
o.afterMilli = false
|
|
o.at = -1
|
|
return o
|
|
}
|
|
|
|
// AtCommitted copies 'o' and returns an offset that is used *only if*
|
|
// there is an existing commit. This is only useful for group consumers.
|
|
// If a partition being consumed does not have a commit, the partition will
|
|
// enter a fatal state and return an error from PollFetches.
|
|
//
|
|
// Using this function automatically opts into [NoResetOffset].
|
|
func (o Offset) AtCommitted() Offset {
|
|
o.noReset = true
|
|
o.afterMilli = false
|
|
o.at = atCommitted
|
|
return o
|
|
}
|
|
|
|
// Relative copies 'o' and returns an offset that starts 'n' relative to what
|
|
// 'o' currently is. If 'o' is at the end (from [AtEnd]), Relative(-100) will
|
|
// begin 100 before the end.
|
|
func (o Offset) Relative(n int64) Offset {
|
|
o.afterMilli = false
|
|
o.relative = n
|
|
return o
|
|
}
|
|
|
|
// WithEpoch copies 'o' and returns an offset with the given epoch. This epoch
|
|
// is used for truncation detection; the default of -1 implies no truncation
|
|
// detection.
|
|
func (o Offset) WithEpoch(e int32) Offset {
|
|
o.afterMilli = false
|
|
if e < 0 {
|
|
e = -1
|
|
}
|
|
o.epoch = e
|
|
return o
|
|
}
|
|
|
|
// At returns a copy of the calling offset, changing the returned offset to
|
|
// begin at exactly the requested offset.
|
|
//
|
|
// There are two potential special offsets to use: -2 allows for consuming at
|
|
// the start, and -1 allows for consuming at the end. These two offsets are
|
|
// equivalent to calling AtStart or AtEnd.
|
|
//
|
|
// If the offset is less than -2, the client bounds it to -2 to consume at the
|
|
// start.
|
|
func (o Offset) At(at int64) Offset {
|
|
o.afterMilli = false
|
|
if at < -2 {
|
|
at = -2
|
|
}
|
|
o.at = at
|
|
return o
|
|
}
|
|
|
|
type consumer struct {
|
|
bufferedRecords atomic.Int64
|
|
bufferedBytes atomic.Int64
|
|
|
|
cl *Client
|
|
|
|
pausedMu sync.Mutex // grabbed when updating paused
|
|
paused atomic.Value // loaded when issuing fetches
|
|
|
|
// mu is grabbed when
|
|
// - polling fetches, for quickly draining sources / updating group uncommitted
|
|
// - calling assignPartitions (group / direct updates)
|
|
mu sync.Mutex
|
|
d *directConsumer // if non-nil, we are consuming partitions directly
|
|
g *groupConsumer // if non-nil, we are consuming as a group member
|
|
|
|
// On metadata update, if the consumer is set (direct or group), the
|
|
// client begins a goroutine that updates the consumer kind's
|
|
// assignments.
|
|
//
|
|
// This is done in a goroutine to not block the metadata loop, because
|
|
// the update **could** wait on a group consumer leaving if a
|
|
// concurrent LeaveGroup is called, or if restarting a session takes
|
|
// just a little bit of time.
|
|
//
|
|
// The update realistically should be instantaneous, but if it is slow,
|
|
// some metadata updates could pile up. We loop with our atomic work
|
|
// loop, which collapses repeated updates into one extra update, so we
|
|
// loop as little as necessary.
|
|
outstandingMetadataUpdates workLoop
|
|
|
|
// sessionChangeMu is grabbed when a session is stopped and held through
|
|
// when a session can be started again. The sole purpose is to block an
|
|
// assignment change running concurrently with a metadata update.
|
|
sessionChangeMu sync.Mutex
|
|
|
|
session atomic.Value // *consumerSession
|
|
kill atomic.Bool
|
|
|
|
usingCursors usedCursors
|
|
|
|
sourcesReadyMu sync.Mutex
|
|
sourcesReadyCond *sync.Cond
|
|
sourcesReadyForDraining []*source
|
|
fakeReadyForDraining []Fetch
|
|
|
|
pollWaitMu sync.Mutex
|
|
pollWaitC *sync.Cond
|
|
pollWaitState uint64 // 0 == nothing, low 32 bits: # pollers, high 32: # waiting rebalances
|
|
}
|
|
|
|
func (c *consumer) loadPaused() pausedTopics { return c.paused.Load().(pausedTopics) }
|
|
func (c *consumer) clonePaused() pausedTopics { return c.paused.Load().(pausedTopics).clone() }
|
|
func (c *consumer) storePaused(p pausedTopics) { c.paused.Store(p) }
|
|
|
|
func (c *consumer) waitAndAddPoller() {
|
|
if !c.cl.cfg.blockRebalanceOnPoll {
|
|
return
|
|
}
|
|
c.pollWaitMu.Lock()
|
|
defer c.pollWaitMu.Unlock()
|
|
if c.pollWaitState&math.MaxUint32 == 0 {
|
|
for c.pollWaitState>>32 != 0 {
|
|
c.pollWaitC.Wait()
|
|
}
|
|
}
|
|
// Rebalance always takes priority, but if there are no active
|
|
// rebalances, our poll blocks rebalances.
|
|
c.pollWaitState++
|
|
}
|
|
|
|
func (c *consumer) unaddPoller() {
|
|
if !c.cl.cfg.blockRebalanceOnPoll {
|
|
return
|
|
}
|
|
c.pollWaitMu.Lock()
|
|
defer c.pollWaitMu.Unlock()
|
|
c.pollWaitState--
|
|
c.pollWaitC.Broadcast()
|
|
}
|
|
|
|
func (c *consumer) allowRebalance() {
|
|
if !c.cl.cfg.blockRebalanceOnPoll {
|
|
return
|
|
}
|
|
c.pollWaitMu.Lock()
|
|
defer c.pollWaitMu.Unlock()
|
|
// When allowing rebalances, the user is explicitly saying all pollers
|
|
// are done. We mask them out.
|
|
c.pollWaitState &= math.MaxUint32 << 32
|
|
c.pollWaitC.Broadcast()
|
|
}
|
|
|
|
func (c *consumer) waitAndAddRebalance() {
|
|
if !c.cl.cfg.blockRebalanceOnPoll {
|
|
return
|
|
}
|
|
var blockedCalled bool
|
|
c.pollWaitMu.Lock()
|
|
defer c.pollWaitMu.Unlock()
|
|
c.pollWaitState += 1 << 32
|
|
for c.pollWaitState&math.MaxUint32 != 0 {
|
|
if !blockedCalled {
|
|
if c.cl.cfg.onBlocked != nil {
|
|
go c.cl.cfg.onBlocked(c.cl.ctx, c.cl)
|
|
}
|
|
blockedCalled = true
|
|
}
|
|
c.pollWaitC.Wait()
|
|
}
|
|
}
|
|
|
|
func (c *consumer) unaddRebalance() {
|
|
if !c.cl.cfg.blockRebalanceOnPoll {
|
|
return
|
|
}
|
|
c.pollWaitMu.Lock()
|
|
defer c.pollWaitMu.Unlock()
|
|
c.pollWaitState -= 1 << 32
|
|
c.pollWaitC.Broadcast()
|
|
}
|
|
|
|
// BufferedFetchRecords returns the number of records currently buffered from
|
|
// fetching within the client.
|
|
//
|
|
// This can be used as a gauge to determine how behind your application is for
|
|
// processing records the client has fetched. Note that it is perfectly normal
|
|
// to see a spike of buffered records, which would correspond to a fetch
|
|
// response being processed just before a call to this function. It is only
|
|
// problematic if for you if this function is consistently returning large
|
|
// values.
|
|
func (cl *Client) BufferedFetchRecords() int64 {
|
|
return cl.consumer.bufferedRecords.Load()
|
|
}
|
|
|
|
// BufferedFetchBytes returns the number of bytes currently buffered from
|
|
// fetching within the client. This is the sum of all keys, values, and header
|
|
// keys/values. See the related [BufferedFetchRecords] for more information.
|
|
func (cl *Client) BufferedFetchBytes() int64 {
|
|
return cl.consumer.bufferedBytes.Load()
|
|
}
|
|
|
|
type usedCursors map[*cursor]struct{}
|
|
|
|
func (u *usedCursors) use(c *cursor) {
|
|
if *u == nil {
|
|
*u = make(map[*cursor]struct{})
|
|
}
|
|
(*u)[c] = struct{}{}
|
|
}
|
|
|
|
func (c *consumer) init(cl *Client) {
|
|
c.cl = cl
|
|
c.paused.Store(make(pausedTopics))
|
|
c.sourcesReadyCond = sync.NewCond(&c.sourcesReadyMu)
|
|
c.pollWaitC = sync.NewCond(&c.pollWaitMu)
|
|
|
|
if len(cl.cfg.topics) > 0 || len(cl.cfg.partitions) > 0 {
|
|
defer cl.triggerUpdateMetadataNow("querying metadata for consumer initialization") // we definitely want to trigger a metadata update
|
|
}
|
|
|
|
if len(cl.cfg.group) == 0 {
|
|
c.initDirect()
|
|
} else {
|
|
c.initGroup()
|
|
}
|
|
}
|
|
|
|
func (c *consumer) consuming() bool {
|
|
return c.g != nil || c.d != nil
|
|
}
|
|
|
|
// addSourceReadyForDraining tracks that a source needs its buffered fetch
|
|
// consumed.
|
|
func (c *consumer) addSourceReadyForDraining(source *source) {
|
|
c.sourcesReadyMu.Lock()
|
|
c.sourcesReadyForDraining = append(c.sourcesReadyForDraining, source)
|
|
c.sourcesReadyMu.Unlock()
|
|
c.sourcesReadyCond.Broadcast()
|
|
}
|
|
|
|
// addFakeReadyForDraining saves a fake fetch that has important partition
|
|
// errors--data loss or auth failures.
|
|
func (c *consumer) addFakeReadyForDraining(topic string, partition int32, err error, why string) {
|
|
c.cl.cfg.logger.Log(LogLevelInfo, "injecting fake fetch with an error", "err", err, "why", why)
|
|
c.sourcesReadyMu.Lock()
|
|
c.fakeReadyForDraining = append(c.fakeReadyForDraining, Fetch{Topics: []FetchTopic{{
|
|
Topic: topic,
|
|
Partitions: []FetchPartition{{
|
|
Partition: partition,
|
|
Err: err,
|
|
}},
|
|
}}})
|
|
c.sourcesReadyMu.Unlock()
|
|
c.sourcesReadyCond.Broadcast()
|
|
}
|
|
|
|
// NewErrFetch returns a fake fetch containing a single empty topic with a
|
|
// single zero partition with the given error.
|
|
func NewErrFetch(err error) Fetches {
|
|
return []Fetch{{
|
|
Topics: []FetchTopic{{
|
|
Topic: "",
|
|
Partitions: []FetchPartition{{
|
|
Partition: -1,
|
|
Err: err,
|
|
}},
|
|
}},
|
|
}}
|
|
}
|
|
|
|
// PollFetches waits for fetches to be available, returning as soon as any
|
|
// broker returns a fetch. If the context is nil, this function will return
|
|
// immediately with any currently buffered records. It is functionally
|
|
// equivalent to calling PollRecords(ctx, 0).
|
|
//
|
|
// If the client is closed, a fake fetch will be injected that has no topic, a
|
|
// partition of 0, and a partition error of ErrClientClosed. If the context is
|
|
// canceled, a fake fetch will be injected with ctx.Err. These injected errors
|
|
// can be used to break out of a poll loop.
|
|
//
|
|
// It is important to check all partition errors in the returned fetches. If
|
|
// any partition has a fatal error and actually had no records, fake fetch will
|
|
// be injected with the error.
|
|
//
|
|
// If you are group consuming, a rebalance can happen under the hood while you
|
|
// process the returned fetches. This can result in duplicate work, and you may
|
|
// accidentally commit to partitions that you no longer own. You can prevent
|
|
// this by using BlockRebalanceOnPoll, but this comes with different tradeoffs.
|
|
// See the documentation on BlockRebalanceOnPoll for more information.
|
|
func (cl *Client) PollFetches(ctx context.Context) Fetches {
|
|
return cl.PollRecords(ctx, 0)
|
|
}
|
|
|
|
// PollRecords waits for fetches to be available, returning as soon as any
|
|
// broker returns a fetch. If the context is nil, this function will return
|
|
// immediately with any currently buffered fetches.
|
|
//
|
|
// If the client is closed, a fake fetch will be injected that has no topic, a
|
|
// partition of -1, and a partition error of ErrClientClosed. If the context is
|
|
// canceled, a fake fetch will be injected with ctx.Err. These injected errors
|
|
// can be used to break out of a poll loop.
|
|
//
|
|
// This returns a maximum of maxPollRecords total across all fetches, or
|
|
// returns all buffered records if maxPollRecords is <= 0.
|
|
//
|
|
// It is important to check all partition errors in the returned fetches. If
|
|
// any partition has a fatal error and actually had no records, fake fetch will
|
|
// be injected with the error.
|
|
//
|
|
// If you are group consuming, a rebalance can happen under the hood while you
|
|
// process the returned fetches. This can result in duplicate work, and you may
|
|
// accidentally commit to partitions that you no longer own. You can prevent
|
|
// this by using BlockRebalanceOnPoll, but this comes with different tradeoffs.
|
|
// See the documentation on BlockRebalanceOnPoll for more information.
|
|
func (cl *Client) PollRecords(ctx context.Context, maxPollRecords int) Fetches {
|
|
if maxPollRecords == 0 {
|
|
maxPollRecords = -1
|
|
}
|
|
c := &cl.consumer
|
|
|
|
c.g.undirtyUncommitted()
|
|
|
|
// If the user gave us a canceled context, we bail immediately after
|
|
// un-dirty-ing marked records.
|
|
if ctx != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
return NewErrFetch(ctx.Err())
|
|
default:
|
|
}
|
|
}
|
|
|
|
var fetches Fetches
|
|
fill := func() {
|
|
if c.cl.cfg.blockRebalanceOnPoll {
|
|
c.waitAndAddPoller()
|
|
defer func() {
|
|
if len(fetches) == 0 {
|
|
c.unaddPoller()
|
|
}
|
|
}()
|
|
}
|
|
|
|
paused := c.loadPaused()
|
|
|
|
// A group can grab the consumer lock then the group mu and
|
|
// assign partitions. The group mu is grabbed to update its
|
|
// uncommitted map. Assigning partitions clears sources ready
|
|
// for draining.
|
|
//
|
|
// We need to grab the consumer mu to ensure proper lock
|
|
// ordering and prevent lock inversion. Polling fetches also
|
|
// updates the group's uncommitted map; if we do not grab the
|
|
// consumer mu at the top, we have a problem: without the lock,
|
|
// we could have grabbed some sources, then a group assigned,
|
|
// and after the assign, we update uncommitted with fetches
|
|
// from the old assignment
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.sourcesReadyMu.Lock()
|
|
if maxPollRecords < 0 {
|
|
for _, ready := range c.sourcesReadyForDraining {
|
|
fetches = append(fetches, ready.takeBuffered(paused))
|
|
}
|
|
c.sourcesReadyForDraining = nil
|
|
} else {
|
|
for len(c.sourcesReadyForDraining) > 0 && maxPollRecords > 0 {
|
|
source := c.sourcesReadyForDraining[0]
|
|
fetch, taken, drained := source.takeNBuffered(paused, maxPollRecords)
|
|
if drained {
|
|
c.sourcesReadyForDraining = c.sourcesReadyForDraining[1:]
|
|
}
|
|
maxPollRecords -= taken
|
|
fetches = append(fetches, fetch)
|
|
}
|
|
}
|
|
|
|
realFetches := fetches
|
|
|
|
fetches = append(fetches, c.fakeReadyForDraining...)
|
|
c.fakeReadyForDraining = nil
|
|
|
|
c.sourcesReadyMu.Unlock()
|
|
|
|
if len(realFetches) == 0 {
|
|
return
|
|
}
|
|
|
|
// Before returning, we want to update our uncommitted. If we
|
|
// updated after, then we could end up with weird interactions
|
|
// with group invalidations where we return a stale fetch after
|
|
// committing in onRevoke.
|
|
//
|
|
// A blocking onRevoke commit, on finish, allows a new group
|
|
// session to start. If we returned stale fetches that did not
|
|
// have their uncommitted offset tracked, then we would allow
|
|
// duplicates.
|
|
if c.g != nil {
|
|
c.g.updateUncommitted(realFetches)
|
|
}
|
|
}
|
|
|
|
// We try filling fetches once before waiting. If we have no context,
|
|
// we guarantee that we just drain anything available and return.
|
|
fill()
|
|
if len(fetches) > 0 || ctx == nil {
|
|
return fetches
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
quit := false
|
|
go func() {
|
|
c.sourcesReadyMu.Lock()
|
|
defer c.sourcesReadyMu.Unlock()
|
|
defer close(done)
|
|
|
|
for !quit && len(c.sourcesReadyForDraining) == 0 && len(c.fakeReadyForDraining) == 0 {
|
|
c.sourcesReadyCond.Wait()
|
|
}
|
|
}()
|
|
|
|
exit := func() {
|
|
c.sourcesReadyMu.Lock()
|
|
quit = true
|
|
c.sourcesReadyMu.Unlock()
|
|
c.sourcesReadyCond.Broadcast()
|
|
}
|
|
|
|
select {
|
|
case <-cl.ctx.Done():
|
|
exit()
|
|
return NewErrFetch(ErrClientClosed)
|
|
case <-ctx.Done():
|
|
exit()
|
|
return NewErrFetch(ctx.Err())
|
|
case <-done:
|
|
}
|
|
|
|
fill()
|
|
return fetches
|
|
}
|
|
|
|
// AllowRebalance allows a consumer group to rebalance if it was blocked by you
|
|
// polling records in tandem with the BlockRebalanceOnPoll option.
|
|
//
|
|
// You can poll many times before calling this function; this function
|
|
// internally resets the poll count and allows any blocked rebalances to
|
|
// continue. Rebalances take priority: if a rebalance is blocked, and you allow
|
|
// rebalances and then immediately poll, your poll will be blocked until the
|
|
// rebalance completes. Internally, this function simply waits for lost
|
|
// partitions to stop being fetched before allowing you to poll again.
|
|
func (cl *Client) AllowRebalance() {
|
|
cl.consumer.allowRebalance()
|
|
}
|
|
|
|
// UpdateFetchMaxBytes updates the max bytes that a fetch request will ask for
|
|
// and the max partition bytes that a fetch request will ask for each
|
|
// partition.
|
|
func (cl *Client) UpdateFetchMaxBytes(maxBytes, maxPartBytes int32) {
|
|
cl.cfg.maxBytes.store(maxBytes)
|
|
cl.cfg.maxPartBytes.store(maxPartBytes)
|
|
}
|
|
|
|
// PauseFetchTopics sets the client to no longer fetch the given topics and
|
|
// returns all currently paused topics. Paused topics persist until resumed.
|
|
// You can call this function with no topics to simply receive the list of
|
|
// currently paused topics.
|
|
//
|
|
// Pausing topics is independent from pausing individual partitions with the
|
|
// PauseFetchPartitions method. If you pause partitions for a topic with
|
|
// PauseFetchPartitions, and then pause that same topic with PauseFetchTopics,
|
|
// the individually paused partitions will not be unpaused if you only call
|
|
// ResumeFetchTopics.
|
|
func (cl *Client) PauseFetchTopics(topics ...string) []string {
|
|
c := &cl.consumer
|
|
if len(topics) == 0 {
|
|
return c.loadPaused().pausedTopics()
|
|
}
|
|
c.pausedMu.Lock()
|
|
defer c.pausedMu.Unlock()
|
|
paused := c.clonePaused()
|
|
paused.addTopics(topics...)
|
|
c.storePaused(paused)
|
|
return paused.pausedTopics()
|
|
}
|
|
|
|
// PauseFetchPartitions sets the client to no longer fetch the given partitions
|
|
// and returns all currently paused partitions. Paused partitions persist until
|
|
// resumed. You can call this function with no partitions to simply receive the
|
|
// list of currently paused partitions.
|
|
//
|
|
// Pausing individual partitions is independent from pausing topics with the
|
|
// PauseFetchTopics method. If you pause partitions for a topic with
|
|
// PauseFetchPartitions, and then pause that same topic with PauseFetchTopics,
|
|
// the individually paused partitions will not be unpaused if you only call
|
|
// ResumeFetchTopics.
|
|
func (cl *Client) PauseFetchPartitions(topicPartitions map[string][]int32) map[string][]int32 {
|
|
c := &cl.consumer
|
|
if len(topicPartitions) == 0 {
|
|
return c.loadPaused().pausedPartitions()
|
|
}
|
|
c.pausedMu.Lock()
|
|
defer c.pausedMu.Unlock()
|
|
paused := c.clonePaused()
|
|
paused.addPartitions(topicPartitions)
|
|
c.storePaused(paused)
|
|
return paused.pausedPartitions()
|
|
}
|
|
|
|
// ResumeFetchTopics resumes fetching the input topics if they were previously
|
|
// paused. Resuming topics that are not currently paused is a per-topic no-op.
|
|
// See the documentation on PauseFetchTopics for more details.
|
|
func (cl *Client) ResumeFetchTopics(topics ...string) {
|
|
defer cl.allSinksAndSources(func(sns sinkAndSource) {
|
|
sns.source.maybeConsume()
|
|
})
|
|
|
|
c := &cl.consumer
|
|
c.pausedMu.Lock()
|
|
defer c.pausedMu.Unlock()
|
|
|
|
paused := c.clonePaused()
|
|
paused.delTopics(topics...)
|
|
c.storePaused(paused)
|
|
}
|
|
|
|
// ResumeFetchPartitions resumes fetching the input partitions if they were
|
|
// previously paused. Resuming partitions that are not currently paused is a
|
|
// per-topic no-op. See the documentation on PauseFetchPartitions for more
|
|
// details.
|
|
func (cl *Client) ResumeFetchPartitions(topicPartitions map[string][]int32) {
|
|
defer cl.allSinksAndSources(func(sns sinkAndSource) {
|
|
sns.source.maybeConsume()
|
|
})
|
|
|
|
c := &cl.consumer
|
|
c.pausedMu.Lock()
|
|
defer c.pausedMu.Unlock()
|
|
|
|
paused := c.clonePaused()
|
|
paused.delPartitions(topicPartitions)
|
|
c.storePaused(paused)
|
|
}
|
|
|
|
// SetOffsets sets any matching offsets in setOffsets to the given
|
|
// epoch/offset. Partitions that are not specified are not set. It is invalid
|
|
// to set topics that were not yet returned from a PollFetches: this function
|
|
// sets only partitions that were previously consumed, any extra partitions are
|
|
// skipped.
|
|
//
|
|
// If directly consuming, this function operates as expected given the caveats
|
|
// of the prior paragraph.
|
|
//
|
|
// If using transactions, it is advised to just use a GroupTransactSession and
|
|
// avoid this function entirely.
|
|
//
|
|
// If using group consuming, It is strongly recommended to use this function
|
|
// outside of the context of a PollFetches loop and only when you know the
|
|
// group is not revoked (i.e., block any concurrent revoke while issuing this
|
|
// call) and to not use this concurrent with committing. Any other usage is
|
|
// prone to odd interactions.
|
|
func (cl *Client) SetOffsets(setOffsets map[string]map[int32]EpochOffset) {
|
|
cl.setOffsets(setOffsets, true)
|
|
}
|
|
|
|
func (cl *Client) setOffsets(setOffsets map[string]map[int32]EpochOffset, log bool) {
|
|
if len(setOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
// We assignPartitions before returning, so we grab the consumer lock
|
|
// first to preserve consumer mu => group mu ordering, or to ensure
|
|
// no concurrent metadata assign for direct consuming.
|
|
c := &cl.consumer
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
var assigns map[string]map[int32]Offset
|
|
var tps *topicsPartitions
|
|
switch {
|
|
case c.d != nil:
|
|
assigns = c.d.getSetAssigns(setOffsets)
|
|
tps = c.d.tps
|
|
case c.g != nil:
|
|
assigns = c.g.getSetAssigns(setOffsets)
|
|
tps = c.g.tps
|
|
}
|
|
if len(assigns) == 0 {
|
|
return
|
|
}
|
|
if log {
|
|
c.assignPartitions(assigns, assignSetMatching, tps, "from manual SetOffsets")
|
|
} else {
|
|
c.assignPartitions(assigns, assignSetMatching, tps, "")
|
|
}
|
|
}
|
|
|
|
// This is guaranteed to be called in a blocking metadata fn, which ensures
|
|
// that metadata does not load the tps we are changing. Basically, we ensure
|
|
// everything w.r.t. consuming is at a stand still.
|
|
func (c *consumer) purgeTopics(topics []string) {
|
|
if c.g == nil && c.d == nil {
|
|
return
|
|
}
|
|
|
|
purgeAssignments := make(map[string]map[int32]Offset, len(topics))
|
|
for _, topic := range topics {
|
|
purgeAssignments[topic] = nil
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// The difference for groups is we need to lock the group and there is
|
|
// a slight type difference in g.using vs d.using.
|
|
//
|
|
// assignPartitions removes the topics from 'tps', which removes them
|
|
// from FUTURE metadata requests meaning they will not be repopulated
|
|
// in the future. Any loaded tps is fine; metadata updates the topic
|
|
// pointers underneath -- metadata does not re-store the tps itself
|
|
// with any new topics that we just purged (except in the case of regex,
|
|
// which is impossible to permanently purge anyway).
|
|
//
|
|
// We are guarded from adding back to 'using' via the consumer mu;
|
|
// this cannot run concurrent with findNewAssignments. Thus, we first
|
|
// purge tps, then clear using, and once the lock releases, findNewAssignments
|
|
// will use the now-purged tps and will not add back to using.
|
|
if c.g != nil {
|
|
c.g.mu.Lock() // required when updating using
|
|
defer c.g.mu.Unlock()
|
|
c.assignPartitions(purgeAssignments, assignPurgeMatching, c.g.tps, fmt.Sprintf("purge of %v requested", topics))
|
|
for _, topic := range topics {
|
|
delete(c.g.using, topic)
|
|
delete(c.g.reSeen, topic)
|
|
}
|
|
c.g.rejoin("rejoin from PurgeFetchTopics")
|
|
} else {
|
|
c.assignPartitions(purgeAssignments, assignPurgeMatching, c.d.tps, fmt.Sprintf("purge of %v requested", topics))
|
|
for _, topic := range topics {
|
|
delete(c.d.using, topic)
|
|
delete(c.d.reSeen, topic)
|
|
delete(c.d.m, topic)
|
|
delete(c.d.ps, topic)
|
|
}
|
|
}
|
|
}
|
|
|
|
// AddConsumeTopics adds new topics to be consumed. This function is a no-op if
|
|
// the client is configured to consume via regex.
|
|
//
|
|
// Note that if you are directly consuming and specified ConsumePartitions,
|
|
// this function will not add the rest of the partitions for a topic unless the
|
|
// topic has been previously purged. That is, if you directly consumed only one
|
|
// of five partitions originally, this will not add the other four until the
|
|
// entire topic is purged.
|
|
func (cl *Client) AddConsumeTopics(topics ...string) {
|
|
c := &cl.consumer
|
|
if len(topics) == 0 || c.g == nil && c.d == nil || cl.cfg.regex {
|
|
return
|
|
}
|
|
|
|
// We can do this outside of the metadata loop because we are strictly
|
|
// adding new topics and forbid regex consuming.
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.g != nil {
|
|
c.g.tps.storeTopics(topics)
|
|
} else {
|
|
c.d.tps.storeTopics(topics)
|
|
for _, topic := range topics {
|
|
c.d.m.addt(topic)
|
|
}
|
|
}
|
|
cl.triggerUpdateMetadataNow("from AddConsumeTopics")
|
|
}
|
|
|
|
// GetConsumeTopics retrives a list of current topics being consumed.
|
|
func (cl *Client) GetConsumeTopics() []string {
|
|
c := &cl.consumer
|
|
if c.g == nil && c.d == nil {
|
|
return nil
|
|
}
|
|
var m map[string]*topicPartitions
|
|
var ok bool
|
|
if c.g != nil {
|
|
m, ok = c.g.tps.v.Load().(topicsPartitionsData)
|
|
} else {
|
|
m, ok = c.d.tps.v.Load().(topicsPartitionsData)
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
topics := make([]string, 0, len(m))
|
|
for k := range m {
|
|
topics = append(topics, k)
|
|
}
|
|
return topics
|
|
}
|
|
|
|
// AddConsumePartitions adds new partitions to be consumed at the given
|
|
// offsets. This function works only for direct, non-regex consumers.
|
|
func (cl *Client) AddConsumePartitions(partitions map[string]map[int32]Offset) {
|
|
c := &cl.consumer
|
|
if c.d == nil || cl.cfg.regex {
|
|
return
|
|
}
|
|
var topics []string
|
|
for t, ps := range partitions {
|
|
if len(ps) == 0 {
|
|
delete(partitions, t)
|
|
continue
|
|
}
|
|
topics = append(topics, t)
|
|
}
|
|
if len(partitions) == 0 {
|
|
return
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.d.tps.storeTopics(topics)
|
|
for t, ps := range partitions {
|
|
if c.d.ps[t] == nil {
|
|
c.d.ps[t] = make(map[int32]Offset)
|
|
}
|
|
for p, o := range ps {
|
|
c.d.m.add(t, p)
|
|
c.d.ps[t][p] = o
|
|
}
|
|
}
|
|
cl.triggerUpdateMetadataNow("from AddConsumePartitions")
|
|
}
|
|
|
|
// RemoveConsumePartitions removes partitions from being consumed. This
|
|
// function works only for direct, non-regex consumers.
|
|
//
|
|
// This method does not purge the concept of any topics from the client -- if
|
|
// you remove all partitions from a topic that was being consumed, metadata
|
|
// fetches will still occur for the topic. If you want to remove the topic
|
|
// entirely, use PurgeTopicsFromClient.
|
|
//
|
|
// If you specified ConsumeTopics and this function removes all partitions for
|
|
// a topic, the topic will no longer be consumed.
|
|
func (cl *Client) RemoveConsumePartitions(partitions map[string][]int32) {
|
|
c := &cl.consumer
|
|
if c.d == nil || cl.cfg.regex {
|
|
return
|
|
}
|
|
for t, ps := range partitions {
|
|
if len(ps) == 0 {
|
|
delete(partitions, t)
|
|
continue
|
|
}
|
|
}
|
|
if len(partitions) == 0 {
|
|
return
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
removeOffsets := make(map[string]map[int32]Offset, len(partitions))
|
|
for t, ps := range partitions {
|
|
removePartitionOffsets := make(map[int32]Offset, len(ps))
|
|
for _, p := range ps {
|
|
removePartitionOffsets[p] = Offset{}
|
|
}
|
|
removeOffsets[t] = removePartitionOffsets
|
|
}
|
|
|
|
c.assignPartitions(removeOffsets, assignInvalidateMatching, c.d.tps, fmt.Sprintf("remove of %v requested", partitions))
|
|
for t, ps := range partitions {
|
|
for _, p := range ps {
|
|
c.d.using.remove(t, p)
|
|
c.d.m.remove(t, p)
|
|
delete(c.d.ps[t], p)
|
|
}
|
|
if len(c.d.ps[t]) == 0 {
|
|
delete(c.d.ps, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assignHow controls how assignPartitions operates.
|
|
type assignHow int8
|
|
|
|
const (
|
|
// This option simply assigns new offsets, doing nothing with existing
|
|
// offsets / active fetches / buffered fetches.
|
|
assignWithoutInvalidating assignHow = iota
|
|
|
|
// This option invalidates active fetches so they will not buffer and
|
|
// drops all buffered fetches, and then continues to assign the new
|
|
// assignments.
|
|
assignInvalidateAll
|
|
|
|
// This option does not assign, but instead invalidates any active
|
|
// fetches for "assigned" (actually lost) partitions. This additionally
|
|
// drops all buffered fetches, because they could contain partitions we
|
|
// lost. Thus, with this option, the actual offset in the map is
|
|
// meaningless / a dummy offset.
|
|
assignInvalidateMatching
|
|
|
|
assignPurgeMatching
|
|
|
|
// The counterpart to assignInvalidateMatching, assignSetMatching
|
|
// resets all matching partitions to the specified offset / epoch.
|
|
assignSetMatching
|
|
)
|
|
|
|
func (h assignHow) String() string {
|
|
switch h {
|
|
case assignWithoutInvalidating:
|
|
return "assigning everything new, keeping current assignment"
|
|
case assignInvalidateAll:
|
|
return "unassigning everything"
|
|
case assignInvalidateMatching:
|
|
return "unassigning any currently assigned matching partition that is in the input"
|
|
case assignPurgeMatching:
|
|
return "unassigning and purging any partition matching the input topics"
|
|
case assignSetMatching:
|
|
return "reassigning any currently assigned matching partition to the input"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type fmtAssignment map[string]map[int32]Offset
|
|
|
|
func (f fmtAssignment) String() string {
|
|
var sb strings.Builder
|
|
|
|
var topicsWritten int
|
|
for topic, partitions := range f {
|
|
topicsWritten++
|
|
sb.WriteString(topic)
|
|
sb.WriteString("[")
|
|
|
|
var partitionsWritten int
|
|
for partition, offset := range partitions {
|
|
fmt.Fprintf(&sb, "%d%s", partition, offset)
|
|
partitionsWritten++
|
|
if partitionsWritten < len(partitions) {
|
|
sb.WriteString(" ")
|
|
}
|
|
}
|
|
|
|
sb.WriteString("]")
|
|
if topicsWritten < len(f) {
|
|
sb.WriteString(", ")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// assignPartitions, called under the consumer's mu, is used to set new cursors
|
|
// or add to the existing cursors.
|
|
//
|
|
// We do not need to pass tps when we are bumping the session or when we are
|
|
// invalidating all. All other cases, we want the tps -- the logic below does
|
|
// not fully differentiate needing to start a new session vs. just reusing the
|
|
// old (third if case below)
|
|
func (c *consumer) assignPartitions(assignments map[string]map[int32]Offset, how assignHow, tps *topicsPartitions, why string) {
|
|
if c.mu.TryLock() {
|
|
c.mu.Unlock()
|
|
panic("assignPartitions called without holding the consumer lock, this is a bug in franz-go, please open an issue at github.com/twmb/franz-go")
|
|
}
|
|
|
|
// The internal code can avoid giving an assign reason in cases where
|
|
// the caller logs itself immediately before assigning. We only log if
|
|
// there is a reason.
|
|
if len(why) > 0 {
|
|
c.cl.cfg.logger.Log(LogLevelInfo, "assigning partitions",
|
|
"why", why,
|
|
"how", how,
|
|
"input", fmtAssignment(assignments),
|
|
)
|
|
}
|
|
var session *consumerSession
|
|
var loadOffsets listOrEpochLoads
|
|
|
|
defer func() {
|
|
if session == nil { // if nil, we stopped the session
|
|
session = c.startNewSession(tps)
|
|
} else { // else we guarded it
|
|
c.unguardSessionChange(session)
|
|
}
|
|
loadOffsets.loadWithSession(session, "loading offsets in new session from assign") // odds are this assign came from a metadata update, so no reason to force a refresh with loadWithSessionNow
|
|
|
|
// If we started a new session or if we unguarded, we have one
|
|
// worker. This one worker allowed us to safely add our load
|
|
// offsets before the session could be concurrently stopped
|
|
// again. Now that we have added the load offsets, we allow the
|
|
// session to be stopped.
|
|
session.decWorker()
|
|
}()
|
|
|
|
if how == assignWithoutInvalidating {
|
|
// Guarding a session change can actually create a new session
|
|
// if we had no session before, which is why we need to pass in
|
|
// our topicPartitions.
|
|
session = c.guardSessionChange(tps)
|
|
} else {
|
|
loadOffsets, _ = c.stopSession()
|
|
|
|
// First, over all cursors currently in use, we unset them or set them
|
|
// directly as appropriate. Anything we do not unset, we keep.
|
|
|
|
var keep usedCursors
|
|
for usedCursor := range c.usingCursors {
|
|
shouldKeep := true
|
|
if how == assignInvalidateAll {
|
|
usedCursor.unset()
|
|
shouldKeep = false
|
|
} else { // invalidateMatching or setMatching
|
|
if assignTopic, ok := assignments[usedCursor.topic]; ok {
|
|
if how == assignPurgeMatching { // topic level
|
|
usedCursor.source.removeCursor(usedCursor)
|
|
shouldKeep = false
|
|
} else if assignPart, ok := assignTopic[usedCursor.partition]; ok {
|
|
if how == assignInvalidateMatching {
|
|
usedCursor.unset()
|
|
shouldKeep = false
|
|
} else { // how == assignSetMatching
|
|
usedCursor.setOffset(cursorOffset{
|
|
offset: assignPart.at,
|
|
lastConsumedEpoch: assignPart.epoch,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if shouldKeep {
|
|
keep.use(usedCursor)
|
|
}
|
|
}
|
|
c.usingCursors = keep
|
|
|
|
// For any partition that was listing offsets or loading
|
|
// epochs, we want to ensure that if we are keeping those
|
|
// partitions, we re-start the list/load.
|
|
//
|
|
// Note that we do not need to unset cursors here; anything
|
|
// that actually resulted in a cursor is forever tracked in
|
|
// usedCursors. We only do not have used cursors if an
|
|
// assignment went straight to listing / epoch loading, and
|
|
// that list/epoch never finished.
|
|
switch how {
|
|
case assignWithoutInvalidating:
|
|
// Nothing to do -- this is handled above.
|
|
case assignInvalidateAll:
|
|
loadOffsets = listOrEpochLoads{}
|
|
case assignSetMatching:
|
|
// We had not yet loaded this partition, so there is
|
|
// nothing to set, and we keep everything.
|
|
case assignInvalidateMatching:
|
|
loadOffsets.keepFilter(func(t string, p int32) bool {
|
|
if assignTopic, ok := assignments[t]; ok {
|
|
if _, ok := assignTopic[p]; ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
case assignPurgeMatching:
|
|
// This is slightly different than invalidate in that
|
|
// we invalidate whole topics.
|
|
loadOffsets.keepFilter(func(t string, _ int32) bool {
|
|
_, ok := assignments[t]
|
|
return !ok // assignments are topics to purge -- do NOT keep the topic if it is being purged
|
|
})
|
|
// We have to purge from tps _after_ the session is
|
|
// stopped. If we purge early while the session is
|
|
// ongoing, then another goroutine could be loading and
|
|
// using tps and expecting topics not yet removed from
|
|
// assignPartitions to still be there. Specifically,
|
|
// mapLoadsToBrokers could be expecting topic foo to be
|
|
// there (from the session!), so if we purge foo before
|
|
// stopping the session, we will panic.
|
|
topics := make([]string, 0, len(assignments))
|
|
for t := range assignments {
|
|
topics = append(topics, t)
|
|
}
|
|
tps.purgeTopics(topics)
|
|
}
|
|
}
|
|
|
|
// This assignment could contain nothing (for the purposes of
|
|
// invalidating active fetches), so we only do this if needed.
|
|
if len(assignments) == 0 || how != assignWithoutInvalidating {
|
|
return
|
|
}
|
|
|
|
c.cl.cfg.logger.Log(LogLevelDebug, "assign requires loading offsets")
|
|
|
|
// We could have a prior handleListOrEpochResults finishing concurrent
|
|
// with a new assignment. We need to guard below, because list/epoch
|
|
// results can set offsets for loaded cursors.
|
|
//
|
|
// Example: I was just cooperatively assigned p1o10e3, and I issued an
|
|
// epoch request to validate data loss. I was then assigned p2o4e-1:
|
|
// there is no epoch to validate, so we immediately begin consuming
|
|
// at the offset. The epoch request returns and tries also using the
|
|
// cursor concurrently. We need to guard it.
|
|
session.listOrEpochMu.Lock()
|
|
defer session.listOrEpochMu.Unlock()
|
|
|
|
topics := tps.load()
|
|
for topic, partitions := range assignments {
|
|
topicPartitions := topics.loadTopic(topic) // should be non-nil
|
|
if topicPartitions == nil {
|
|
c.cl.cfg.logger.Log(LogLevelError, "BUG! consumer was assigned topic that we did not ask for in ConsumeTopics nor ConsumePartitions, skipping!", "topic", topic)
|
|
continue
|
|
}
|
|
|
|
for partition, offset := range partitions {
|
|
// If we are loading the first record after a millisec,
|
|
// we go directly to listing offsets. Epoch validation
|
|
// does not ever set afterMilli.
|
|
if offset.afterMilli {
|
|
loadOffsets.addLoad(topic, partition, loadTypeList, offsetLoad{
|
|
replica: -1,
|
|
Offset: offset,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// First, if the request is exact, get rid of the relative
|
|
// portion. We are modifying a copy of the offset, i.e. we
|
|
// are appropriately not modfying 'assignments' itself.
|
|
if offset.at >= 0 {
|
|
offset.at += offset.relative
|
|
if offset.at < 0 {
|
|
offset.at = 0
|
|
}
|
|
offset.relative = 0
|
|
}
|
|
|
|
// If we are requesting an exact offset with an epoch,
|
|
// we do truncation detection and then use the offset.
|
|
//
|
|
// Otherwise, an epoch is specified without an exact
|
|
// request which is useless for us, or a request is
|
|
// specified without a known epoch.
|
|
//
|
|
// The client ensures the epoch is non-negative from
|
|
// fetch offsets only if the broker supports KIP-320,
|
|
// but we do not override the user manually specifying
|
|
// an epoch.
|
|
if offset.at >= 0 && offset.epoch >= 0 {
|
|
loadOffsets.addLoad(topic, partition, loadTypeEpoch, offsetLoad{
|
|
replica: -1,
|
|
Offset: offset,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// If an exact offset is specified and we have loaded
|
|
// the partition, we use it. We have to use epoch -1
|
|
// rather than the latest loaded epoch on the partition
|
|
// because the offset being requested to use could be
|
|
// from an epoch after OUR loaded epoch. Otherwise, we
|
|
// could update the metadata, see the later epoch,
|
|
// request the end offset for our prior epoch, and then
|
|
// think data loss occurred.
|
|
//
|
|
// If an offset is unspecified or we have not loaded
|
|
// the partition, we list offsets to find out what to
|
|
// use.
|
|
if offset.at >= 0 && partition >= 0 && partition < int32(len(topicPartitions.partitions)) {
|
|
part := topicPartitions.partitions[partition]
|
|
cursor := part.cursor
|
|
cursor.setOffset(cursorOffset{
|
|
offset: offset.at,
|
|
lastConsumedEpoch: -1,
|
|
})
|
|
cursor.allowUsable()
|
|
c.usingCursors.use(cursor)
|
|
continue
|
|
}
|
|
|
|
// If the offset is atCommitted, then no offset was
|
|
// loaded from FetchOffsets. We inject an error and
|
|
// avoid using this partition.
|
|
if offset.at == atCommitted {
|
|
c.addFakeReadyForDraining(topic, partition, errNoCommittedOffset, "notification of uncommitted partition")
|
|
continue
|
|
}
|
|
|
|
loadOffsets.addLoad(topic, partition, loadTypeList, offsetLoad{
|
|
replica: -1,
|
|
Offset: offset,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// filterMetadataAllTopics, called BEFORE doOnMetadataUpdate, evaluates
|
|
// all topics received against the user provided regex.
|
|
func (c *consumer) filterMetadataAllTopics(topics []string) []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
var rns reNews
|
|
defer rns.log(&c.cl.cfg)
|
|
|
|
var reSeen map[string]bool
|
|
if c.d != nil {
|
|
reSeen = c.d.reSeen
|
|
} else {
|
|
reSeen = c.g.reSeen
|
|
}
|
|
|
|
keep := topics[:0]
|
|
for _, topic := range topics {
|
|
want, seen := reSeen[topic]
|
|
if !seen {
|
|
for rawRe, re := range c.cl.cfg.topics {
|
|
if want = re.MatchString(topic); want {
|
|
rns.add(rawRe, topic)
|
|
break
|
|
}
|
|
}
|
|
if want {
|
|
for _, re := range c.cl.cfg.excludeTopics {
|
|
if re.MatchString(topic) {
|
|
want = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !want {
|
|
rns.skip(topic)
|
|
}
|
|
reSeen[topic] = want
|
|
}
|
|
if want {
|
|
keep = append(keep, topic)
|
|
}
|
|
}
|
|
return keep
|
|
}
|
|
|
|
func (c *consumer) doOnMetadataUpdate() {
|
|
if !c.consuming() {
|
|
return
|
|
}
|
|
|
|
// See the comment on the outstandingMetadataUpdates field for why this
|
|
// block below.
|
|
if c.outstandingMetadataUpdates.maybeBegin() {
|
|
doUpdate := func() {
|
|
// We forbid reassignments while we do a quick check for
|
|
// new assignments--for the direct consumer particularly,
|
|
// this prevents TOCTOU, and guards against a concurrent
|
|
// assignment from SetOffsets.
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
switch {
|
|
case c.d != nil:
|
|
if new := c.d.findNewAssignments(); len(new) > 0 {
|
|
c.assignPartitions(new, assignWithoutInvalidating, c.d.tps, "new assignments from direct consumer")
|
|
}
|
|
case c.g != nil:
|
|
c.g.findNewAssignments()
|
|
}
|
|
|
|
go c.loadSession().doOnMetadataUpdate()
|
|
}
|
|
|
|
go func() {
|
|
again := true
|
|
for again {
|
|
doUpdate()
|
|
again = c.outstandingMetadataUpdates.maybeFinish(false)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (s *consumerSession) doOnMetadataUpdate() {
|
|
if s == nil || s == noConsumerSession { // no session started yet
|
|
return
|
|
}
|
|
|
|
s.listOrEpochMu.Lock()
|
|
defer s.listOrEpochMu.Unlock()
|
|
|
|
if s.listOrEpochMetaCh == nil {
|
|
return // nothing waiting to load epochs / offsets
|
|
}
|
|
select {
|
|
case s.listOrEpochMetaCh <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
type offsetLoadMap map[string]map[int32]offsetLoad
|
|
|
|
// offsetLoad is effectively an Offset, but also includes a potential replica
|
|
// to directly use if a cursor had a preferred replica.
|
|
type offsetLoad struct {
|
|
replica int32 // -1 means leader
|
|
Offset
|
|
}
|
|
|
|
func (o offsetLoad) MarshalJSON() ([]byte, error) {
|
|
if o.replica == -1 {
|
|
return o.Offset.MarshalJSON()
|
|
}
|
|
if o.relative == 0 {
|
|
return fmt.Appendf(nil, `{"Replica":%d,"At":%d,"Epoch":%d,"CurrentEpoch":%d}`, o.replica, o.at, o.epoch, o.currentEpoch), nil
|
|
}
|
|
return fmt.Appendf(nil, `{"Replica":%d,"At":%d,"Relative":%d,"Epoch":%d,"CurrentEpoch":%d}`, o.replica, o.at, o.relative, o.epoch, o.currentEpoch), nil
|
|
}
|
|
|
|
func (o offsetLoadMap) errToLoaded(err error) []loadedOffset {
|
|
var loaded []loadedOffset
|
|
for t, ps := range o {
|
|
for p, o := range ps {
|
|
loaded = append(loaded, loadedOffset{
|
|
topic: t,
|
|
partition: p,
|
|
err: err,
|
|
request: o,
|
|
})
|
|
}
|
|
}
|
|
return loaded
|
|
}
|
|
|
|
// Combines list and epoch loads into one type for simplicity.
|
|
type listOrEpochLoads struct {
|
|
// List and Epoch are public so that anything marshaling through
|
|
// reflect (i.e. json) can see the fields.
|
|
List offsetLoadMap
|
|
Epoch offsetLoadMap
|
|
}
|
|
|
|
type listOrEpochLoadType uint8
|
|
|
|
const (
|
|
loadTypeList listOrEpochLoadType = iota
|
|
loadTypeEpoch
|
|
)
|
|
|
|
func (l listOrEpochLoadType) String() string {
|
|
switch l {
|
|
case loadTypeList:
|
|
return "list"
|
|
default:
|
|
return "epoch"
|
|
}
|
|
}
|
|
|
|
// adds an offset to be loaded, ensuring it exists only in the final loadType.
|
|
func (l *listOrEpochLoads) addLoad(t string, p int32, loadType listOrEpochLoadType, load offsetLoad) {
|
|
l.removeLoad(t, p)
|
|
dst := &l.List
|
|
if loadType == loadTypeEpoch {
|
|
dst = &l.Epoch
|
|
}
|
|
|
|
if *dst == nil {
|
|
*dst = make(offsetLoadMap)
|
|
}
|
|
ps := (*dst)[t]
|
|
if ps == nil {
|
|
ps = make(map[int32]offsetLoad)
|
|
(*dst)[t] = ps
|
|
}
|
|
ps[p] = load
|
|
}
|
|
|
|
func (l *listOrEpochLoads) removeLoad(t string, p int32) {
|
|
for _, m := range []offsetLoadMap{
|
|
l.List,
|
|
l.Epoch,
|
|
} {
|
|
if m == nil {
|
|
continue
|
|
}
|
|
ps := m[t]
|
|
if ps == nil {
|
|
continue
|
|
}
|
|
delete(ps, p)
|
|
if len(ps) == 0 {
|
|
delete(m, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (l listOrEpochLoads) each(fn func(string, int32)) {
|
|
for _, m := range []offsetLoadMap{
|
|
l.List,
|
|
l.Epoch,
|
|
} {
|
|
for topic, partitions := range m {
|
|
for partition := range partitions {
|
|
fn(topic, partition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (l *listOrEpochLoads) keepFilter(keep func(string, int32) bool) {
|
|
for _, m := range []offsetLoadMap{
|
|
l.List,
|
|
l.Epoch,
|
|
} {
|
|
for t, ps := range m {
|
|
for p := range ps {
|
|
if !keep(t, p) {
|
|
delete(ps, p)
|
|
if len(ps) == 0 {
|
|
delete(m, t)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merges loads into the caller; used to coalesce loads while a metadata update
|
|
// is happening (see the only use below).
|
|
func (l *listOrEpochLoads) mergeFrom(src listOrEpochLoads) {
|
|
for _, srcs := range []struct {
|
|
m offsetLoadMap
|
|
loadType listOrEpochLoadType
|
|
}{
|
|
{src.List, loadTypeList},
|
|
{src.Epoch, loadTypeEpoch},
|
|
} {
|
|
for t, ps := range srcs.m {
|
|
for p, load := range ps {
|
|
l.addLoad(t, p, srcs.loadType, load)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (l listOrEpochLoads) isEmpty() bool { return len(l.List) == 0 && len(l.Epoch) == 0 }
|
|
|
|
func (l listOrEpochLoads) loadWithSession(s *consumerSession, why string) {
|
|
if !l.isEmpty() {
|
|
s.incWorker()
|
|
go s.listOrEpoch(l, false, why)
|
|
}
|
|
}
|
|
|
|
func (l listOrEpochLoads) loadWithSessionNow(s *consumerSession, why string) bool {
|
|
if !l.isEmpty() {
|
|
s.incWorker()
|
|
go s.listOrEpoch(l, true, why)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A consumer session is responsible for an era of fetching records for a set
|
|
// of cursors. The set can be added to without killing an active session, but
|
|
// it cannot be removed from. Removing any cursor from being consumed kills the
|
|
// current consumer session and begins a new one.
|
|
type consumerSession struct {
|
|
c *consumer
|
|
|
|
ctx context.Context
|
|
cancel func()
|
|
|
|
// tps tracks the topics that were assigned in this session. We use
|
|
// this field to build and handle list offset / load epoch requests.
|
|
tps *topicsPartitions
|
|
|
|
// desireFetchCh is sized to the number of concurrent fetches we are
|
|
// configured to be able to send.
|
|
//
|
|
// We receive desires from sources, we reply when they can fetch, and
|
|
// they send back when they are done. Thus, three level chan.
|
|
desireFetchCh chan chan chan struct{}
|
|
cancelFetchCh chan chan chan struct{}
|
|
allowedFetches int
|
|
fetchManagerStarted atomic.Bool // atomic, once true, we start the fetch manager
|
|
|
|
// Workers signify the number of fetch and list / epoch goroutines that
|
|
// are currently running within the context of this consumer session.
|
|
// Stopping a session only returns once workers hits zero.
|
|
workersMu sync.Mutex
|
|
workersCond *sync.Cond
|
|
workers int
|
|
|
|
// listOrEpochMu largely guards the below. It is a sub-mutex of the
|
|
// consumer mutex to guard one concurrent data access (see below in
|
|
// assignPartitions).
|
|
listOrEpochMu sync.Mutex
|
|
listOrEpochLoadsWaiting listOrEpochLoads
|
|
listOrEpochMetaCh chan struct{} // non-nil if Loads is non-nil, signalled on meta update
|
|
listOrEpochLoadsLoading listOrEpochLoads
|
|
}
|
|
|
|
func (c *consumer) newConsumerSession(tps *topicsPartitions) *consumerSession {
|
|
if tps == nil || len(tps.load()) == 0 {
|
|
return noConsumerSession
|
|
}
|
|
ctx, cancel := context.WithCancel(c.cl.ctx)
|
|
session := &consumerSession{
|
|
c: c,
|
|
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
|
|
tps: tps,
|
|
|
|
// NOTE: This channel must be unbuffered. If it is buffered,
|
|
// then we can exit manageFetchConcurrency when we should not
|
|
// and have a deadlock:
|
|
//
|
|
// * source sends to desireFetchCh, is buffered
|
|
// * source seeds context canceled, tries sending to cancelFetchCh
|
|
// * session concurrently sees context canceled
|
|
// * session has not drained desireFetchCh, sees activeFetches is 0
|
|
// * session exits
|
|
// * source permanently hangs sending to desireFetchCh
|
|
//
|
|
// By having desireFetchCh unbuffered, we *ensure* that if the
|
|
// source indicates it wants a fetch, the session knows it and
|
|
// tracks it in wantFetch.
|
|
//
|
|
// See #198.
|
|
desireFetchCh: make(chan chan chan struct{}),
|
|
|
|
cancelFetchCh: make(chan chan chan struct{}, 4),
|
|
allowedFetches: c.cl.cfg.maxConcurrentFetches,
|
|
}
|
|
session.workersCond = sync.NewCond(&session.workersMu)
|
|
return session
|
|
}
|
|
|
|
func (s *consumerSession) desireFetch() chan chan chan struct{} {
|
|
if !s.fetchManagerStarted.Swap(true) {
|
|
go s.manageFetchConcurrency()
|
|
}
|
|
return s.desireFetchCh
|
|
}
|
|
|
|
func (s *consumerSession) manageFetchConcurrency() {
|
|
var (
|
|
activeFetches int
|
|
doneFetch = make(chan struct{}, 20)
|
|
wantFetch []chan chan struct{}
|
|
|
|
ctxCh = s.ctx.Done()
|
|
wantQuit bool
|
|
)
|
|
for {
|
|
select {
|
|
case register := <-s.desireFetchCh:
|
|
wantFetch = append(wantFetch, register)
|
|
case cancel := <-s.cancelFetchCh:
|
|
var found bool
|
|
for i, want := range wantFetch {
|
|
if want == cancel {
|
|
wantFetch = slices.Delete(wantFetch, i, i+1)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
// If we did not find the channel, then we have already
|
|
// sent to it, removed it from our wantFetch list, and
|
|
// bumped activeFetches.
|
|
if !found {
|
|
activeFetches--
|
|
}
|
|
|
|
case <-doneFetch:
|
|
activeFetches--
|
|
case <-ctxCh:
|
|
wantQuit = true
|
|
ctxCh = nil
|
|
}
|
|
|
|
if len(wantFetch) > 0 && (activeFetches < s.allowedFetches || s.allowedFetches == 0) { // 0 means unbounded
|
|
wantFetch[0] <- doneFetch
|
|
wantFetch = wantFetch[1:]
|
|
activeFetches++
|
|
continue
|
|
}
|
|
|
|
if wantQuit && activeFetches == 0 {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *consumerSession) incWorker() {
|
|
if s == noConsumerSession { // from startNewSession
|
|
return
|
|
}
|
|
s.workersMu.Lock()
|
|
defer s.workersMu.Unlock()
|
|
s.workers++
|
|
}
|
|
|
|
func (s *consumerSession) decWorker() {
|
|
if s == noConsumerSession { // from followup to startNewSession
|
|
return
|
|
}
|
|
s.workersMu.Lock()
|
|
defer s.workersMu.Unlock()
|
|
s.workers--
|
|
if s.workers == 0 {
|
|
s.workersCond.Broadcast()
|
|
}
|
|
}
|
|
|
|
// noConsumerSession exists because we cannot store nil into an atomic.Value.
|
|
var noConsumerSession = new(consumerSession)
|
|
|
|
func (c *consumer) loadSession() *consumerSession {
|
|
if session := c.session.Load(); session != nil {
|
|
return session.(*consumerSession)
|
|
}
|
|
return noConsumerSession
|
|
}
|
|
|
|
// Guards against a session being stopped, and must be paired with an unguard.
|
|
// This returns a new session if there was no session.
|
|
//
|
|
// The purpose of this function is when performing additive-only changes to an
|
|
// existing session, because additive-only changes can avoid killing a running
|
|
// session.
|
|
func (c *consumer) guardSessionChange(tps *topicsPartitions) *consumerSession {
|
|
c.sessionChangeMu.Lock()
|
|
|
|
session := c.loadSession()
|
|
if session == noConsumerSession {
|
|
// If there is no session, we simply store one. This is fine;
|
|
// sources will be able to begin a fetch loop, but they will
|
|
// have no cursors to consume yet.
|
|
session = c.newConsumerSession(tps)
|
|
c.session.Store(session)
|
|
}
|
|
|
|
return session
|
|
}
|
|
|
|
// For the same reason below as in startNewSession, we inc a worker before
|
|
// unguarding. This allows the unguarding to execute a bit of logic if
|
|
// necessary before the session can be stopped.
|
|
func (c *consumer) unguardSessionChange(session *consumerSession) {
|
|
session.incWorker()
|
|
c.sessionChangeMu.Unlock()
|
|
}
|
|
|
|
// Stops an active consumer session if there is one, and does not return until
|
|
// all fetching, listing, offset for leader epoching is complete. This
|
|
// invalidates any buffered fetches for the previous session and returns any
|
|
// partitions that were listing offsets or loading epochs.
|
|
func (c *consumer) stopSession() (listOrEpochLoads, *topicsPartitions) {
|
|
c.sessionChangeMu.Lock()
|
|
|
|
session := c.loadSession()
|
|
|
|
if session == noConsumerSession {
|
|
return listOrEpochLoads{}, nil // we had no session
|
|
}
|
|
|
|
// Before storing noConsumerSession, cancel our old. This pairs
|
|
// with the reverse ordering in source, which checks noConsumerSession
|
|
// then checks the session context.
|
|
session.cancel()
|
|
|
|
// At this point, any in progress fetches, offset lists, or epoch loads
|
|
// will quickly die.
|
|
|
|
c.session.Store(noConsumerSession)
|
|
|
|
// At this point, no source can be started, because the session is
|
|
// noConsumerSession.
|
|
|
|
session.workersMu.Lock()
|
|
for session.workers > 0 {
|
|
session.workersCond.Wait()
|
|
}
|
|
session.workersMu.Unlock()
|
|
|
|
// At this point, all fetches, lists, and loads are dead. We can close
|
|
// our num-fetches manager without worrying about a source trying to
|
|
// register itself.
|
|
|
|
c.cl.allSinksAndSources(func(sns sinkAndSource) {
|
|
sns.source.session.reset()
|
|
})
|
|
|
|
// At this point, if we begin fetching anew, then the sources will not
|
|
// be using stale fetch sessions.
|
|
|
|
c.sourcesReadyMu.Lock()
|
|
defer c.sourcesReadyMu.Unlock()
|
|
for _, ready := range c.sourcesReadyForDraining {
|
|
ready.discardBuffered()
|
|
}
|
|
c.sourcesReadyForDraining = nil
|
|
|
|
// At this point, we have invalidated any buffered data from the prior
|
|
// session. We leave any fake things that were ready so that the user
|
|
// can act on errors. The session is dead.
|
|
|
|
session.listOrEpochLoadsWaiting.mergeFrom(session.listOrEpochLoadsLoading)
|
|
return session.listOrEpochLoadsWaiting, session.tps
|
|
}
|
|
|
|
// Starts a new consumer session, allowing fetches to happen.
|
|
//
|
|
// If there are no topic partitions to start with, this returns noConsumerSession.
|
|
//
|
|
// This is returned with 1 worker; decWorker must be called after return. The
|
|
// 1 worker allows for initialization work to prevent the session from being
|
|
// immediately stopped.
|
|
func (c *consumer) startNewSession(tps *topicsPartitions) *consumerSession {
|
|
if c.kill.Load() {
|
|
tps = nil
|
|
}
|
|
session := c.newConsumerSession(tps)
|
|
c.session.Store(session)
|
|
|
|
// Ensure that this session is usable before being stopped immediately.
|
|
// The caller must dec workers.
|
|
session.incWorker()
|
|
|
|
// At this point, sources can start consuming.
|
|
|
|
c.sessionChangeMu.Unlock()
|
|
|
|
c.cl.allSinksAndSources(func(sns sinkAndSource) {
|
|
sns.source.maybeConsume()
|
|
})
|
|
|
|
// At this point, any source that was not consuming becauase it saw the
|
|
// session was stopped has been notified to potentially start consuming
|
|
// again. The session is alive.
|
|
|
|
return session
|
|
}
|
|
|
|
// This function is responsible for issuing ListOffsets or
|
|
// OffsetForLeaderEpoch. These requests's responses are only handled within
|
|
// the context of a consumer session.
|
|
func (s *consumerSession) listOrEpoch(waiting listOrEpochLoads, immediate bool, why string) {
|
|
defer s.decWorker()
|
|
|
|
// It is possible for a metadata update to try to migrate partition
|
|
// loads if the update moves partitions between brokers. If we are
|
|
// closing the client, the consumer session could already be stopped,
|
|
// but this stops before the metadata goroutine is killed. So, if we
|
|
// are in this function but actually have no session, we return.
|
|
if s == noConsumerSession {
|
|
return
|
|
}
|
|
|
|
wait := true
|
|
if immediate {
|
|
s.c.cl.triggerUpdateMetadataNow(why)
|
|
} else {
|
|
wait = s.c.cl.triggerUpdateMetadata(false, why) // avoid trigger if within refresh interval
|
|
}
|
|
|
|
s.listOrEpochMu.Lock() // collapse any listOrEpochs that occur during meta update into one
|
|
if !s.listOrEpochLoadsWaiting.isEmpty() {
|
|
s.listOrEpochLoadsWaiting.mergeFrom(waiting)
|
|
s.listOrEpochMu.Unlock()
|
|
return
|
|
}
|
|
s.listOrEpochLoadsWaiting = waiting
|
|
s.listOrEpochMetaCh = make(chan struct{}, 1)
|
|
s.listOrEpochMu.Unlock()
|
|
|
|
if wait {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case <-s.listOrEpochMetaCh:
|
|
}
|
|
}
|
|
|
|
s.listOrEpochMu.Lock()
|
|
loading := s.listOrEpochLoadsWaiting
|
|
s.listOrEpochLoadsLoading.mergeFrom(loading)
|
|
s.listOrEpochLoadsWaiting = listOrEpochLoads{}
|
|
s.listOrEpochMetaCh = nil
|
|
s.listOrEpochMu.Unlock()
|
|
|
|
brokerLoads := s.mapLoadsToBrokers(loading)
|
|
|
|
results := make(chan loadedOffsets, 2*len(brokerLoads)) // each broker can receive up to two requests
|
|
|
|
var issued, received int
|
|
for broker, brokerLoad := range brokerLoads {
|
|
s.c.cl.cfg.logger.Log(LogLevelDebug, "offsets to load broker", "broker", broker.meta.NodeID, "load", brokerLoad)
|
|
if len(brokerLoad.List) > 0 {
|
|
issued++
|
|
go s.c.cl.listOffsetsForBrokerLoad(s.ctx, broker, brokerLoad.List, s.tps, results)
|
|
}
|
|
if len(brokerLoad.Epoch) > 0 {
|
|
issued++
|
|
go s.c.cl.loadEpochsForBrokerLoad(s.ctx, broker, brokerLoad.Epoch, s.tps, results)
|
|
}
|
|
}
|
|
|
|
var reloads listOrEpochLoads
|
|
defer func() {
|
|
if !reloads.isEmpty() {
|
|
s.incWorker()
|
|
go func() {
|
|
// Before we dec our worker, we must add the
|
|
// reloads back into the session's waiting loads.
|
|
// Doing so allows a concurrent stopSession to
|
|
// track the waiting loads, whereas if we did not
|
|
// add things back to the session, we could abandon
|
|
// loading these offsets and have a stuck cursor.
|
|
defer s.decWorker()
|
|
defer reloads.loadWithSession(s, "reload offsets from load failure")
|
|
after := time.NewTimer(time.Second)
|
|
defer after.Stop()
|
|
select {
|
|
case <-after.C:
|
|
case <-s.ctx.Done():
|
|
return
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
|
|
for received != issued {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
// If we return early, our session was canceled. We do
|
|
// not move loading list or epoch loads back to
|
|
// waiting; the session stopping manages that.
|
|
return
|
|
case loaded := <-results:
|
|
received++
|
|
reloads.mergeFrom(s.handleListOrEpochResults(loaded))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Called within a consumer session, this function handles results from list
|
|
// offsets or epoch loads and returns any loads that should be retried.
|
|
//
|
|
// To us, all errors are reloadable. We either have request level retryable
|
|
// errors (unknown partition, etc) or non-retryable errors (auth), or we have
|
|
// request issuing errors (no dial, connection cut repeatedly).
|
|
//
|
|
// For retryable request errors, we may as well back off a little bit to allow
|
|
// Kafka to harmonize if the topic exists / etc.
|
|
//
|
|
// For non-retryable request errors, we may as well retry to both (a) allow the
|
|
// user more signals about a problem that they can maybe fix within Kafka (i.e.
|
|
// the auth), and (b) force the user to notice errors.
|
|
//
|
|
// For request issuing errors, we may as well continue to retry because there
|
|
// is not much else we can do. RequestWith already retries, but returns when
|
|
// the retry limit is hit. We will backoff 1s and then allow RequestWith to
|
|
// continue requesting and backing off.
|
|
func (s *consumerSession) handleListOrEpochResults(loaded loadedOffsets) (reloads listOrEpochLoads) {
|
|
// This function can be running twice concurrently, so we need to guard
|
|
// listOrEpochLoadsLoading and usingCursors. For simplicity, we just
|
|
// guard this entire function.
|
|
|
|
debug := s.c.cl.cfg.logger.Level() >= LogLevelDebug
|
|
|
|
var using map[string]map[int32]EpochOffset
|
|
type epochOffsetWhy struct {
|
|
EpochOffset
|
|
error
|
|
}
|
|
var reloading map[string]map[int32]epochOffsetWhy
|
|
if debug {
|
|
using = make(map[string]map[int32]EpochOffset)
|
|
reloading = make(map[string]map[int32]epochOffsetWhy)
|
|
defer func() {
|
|
t := "list"
|
|
if loaded.loadType == loadTypeEpoch {
|
|
t = "epoch"
|
|
}
|
|
s.c.cl.cfg.logger.Log(LogLevelDebug, fmt.Sprintf("handled %s results", t), "broker", logID(loaded.broker), "using", using, "reloading", reloading)
|
|
}()
|
|
}
|
|
|
|
s.listOrEpochMu.Lock()
|
|
defer s.listOrEpochMu.Unlock()
|
|
|
|
for _, load := range loaded.loaded {
|
|
s.listOrEpochLoadsLoading.removeLoad(load.topic, load.partition) // remove the tracking of this load from our session
|
|
|
|
use := func() {
|
|
if debug {
|
|
tusing := using[load.topic]
|
|
if tusing == nil {
|
|
tusing = make(map[int32]EpochOffset)
|
|
using[load.topic] = tusing
|
|
}
|
|
tusing[load.partition] = EpochOffset{load.leaderEpoch, load.offset}
|
|
}
|
|
|
|
load.cursor.setOffset(cursorOffset{
|
|
offset: load.offset,
|
|
lastConsumedEpoch: load.leaderEpoch,
|
|
})
|
|
load.cursor.allowUsable()
|
|
s.c.usingCursors.use(load.cursor)
|
|
}
|
|
|
|
var edl *ErrDataLoss
|
|
switch {
|
|
case errors.As(load.err, &edl):
|
|
s.c.addFakeReadyForDraining(load.topic, load.partition, load.err, "notification of data loss") // signal we lost data, but set the cursor to what we can
|
|
use()
|
|
|
|
case load.err == nil:
|
|
use()
|
|
|
|
default: // from ErrorCode in a response, or broker request err, or request is canceled as our session is ending
|
|
reloads.addLoad(load.topic, load.partition, loaded.loadType, load.request)
|
|
if !kerr.IsRetriable(load.err) && !isRetryableBrokerErr(load.err) && !isDialNonTimeoutErr(load.err) && !isContextErr(load.err) { // non-retryable response error; signal such in a response
|
|
s.c.addFakeReadyForDraining(load.topic, load.partition, load.err, fmt.Sprintf("notification of non-retryable error from %s request", loaded.loadType))
|
|
}
|
|
|
|
if debug {
|
|
treloading := reloading[load.topic]
|
|
if treloading == nil {
|
|
treloading = make(map[int32]epochOffsetWhy)
|
|
reloading[load.topic] = treloading
|
|
}
|
|
treloading[load.partition] = epochOffsetWhy{EpochOffset{load.leaderEpoch, load.offset}, load.err}
|
|
}
|
|
}
|
|
}
|
|
|
|
return reloads
|
|
}
|
|
|
|
// Splits the loads into per-broker loads, mapping each partition to the broker
|
|
// that leads that partition.
|
|
func (s *consumerSession) mapLoadsToBrokers(loads listOrEpochLoads) map[*broker]listOrEpochLoads {
|
|
brokerLoads := make(map[*broker]listOrEpochLoads)
|
|
|
|
s.c.cl.brokersMu.RLock() // hold mu so we can check if partition leaders exist
|
|
defer s.c.cl.brokersMu.RUnlock()
|
|
|
|
brokers := s.c.cl.brokers
|
|
seed := s.c.cl.loadSeeds()[0]
|
|
|
|
topics := s.tps.load()
|
|
for _, loads := range []struct {
|
|
m offsetLoadMap
|
|
loadType listOrEpochLoadType
|
|
}{
|
|
{loads.List, loadTypeList},
|
|
{loads.Epoch, loadTypeEpoch},
|
|
} {
|
|
for topic, partitions := range loads.m {
|
|
topicPartitions := topics.loadTopic(topic) // this must exist, it not existing would be a bug
|
|
for partition, offset := range partitions {
|
|
// We default to the first seed broker if we have no loaded
|
|
// the broker leader for this partition (we should have).
|
|
// Worst case, we get an error for the partition and retry.
|
|
broker := seed
|
|
if partition >= 0 && partition < int32(len(topicPartitions.partitions)) {
|
|
topicPartition := topicPartitions.partitions[partition]
|
|
brokerID := topicPartition.leader
|
|
if offset.replica != -1 {
|
|
// If we are fetching from a follower, we can list
|
|
// offsets against the follower itself. The replica
|
|
// being non-negative signals that.
|
|
//
|
|
// Note this is not actually true (i.e. KIP-392 lies),
|
|
// but we keep this logic in case we can revert
|
|
// to using non-leaders someday.
|
|
brokerID = offset.replica
|
|
}
|
|
if tryBroker := findBroker(brokers, brokerID); tryBroker != nil {
|
|
broker = tryBroker
|
|
}
|
|
offset.currentEpoch = topicPartition.leaderEpoch // ensure we set our latest epoch for the partition
|
|
}
|
|
|
|
brokerLoad := brokerLoads[broker]
|
|
brokerLoad.addLoad(topic, partition, loads.loadType, offset)
|
|
brokerLoads[broker] = brokerLoad
|
|
}
|
|
}
|
|
}
|
|
|
|
return brokerLoads
|
|
}
|
|
|
|
// The result of ListOffsets or OffsetForLeaderEpoch for an individual
|
|
// partition.
|
|
type loadedOffset struct {
|
|
topic string
|
|
partition int32
|
|
|
|
// The following three are potentially unset if the error is non-nil
|
|
// and not ErrDataLoss; these are what we loaded.
|
|
cursor *cursor
|
|
offset int64
|
|
leaderEpoch int32
|
|
|
|
// Any error encountered for loading this partition, or for epoch
|
|
// loading, potentially ErrDataLoss. If this error is not retryable, we
|
|
// avoid reloading the offset and instead inject a fake partition for
|
|
// PollFetches containing this error.
|
|
err error
|
|
|
|
// The original request.
|
|
request offsetLoad
|
|
}
|
|
|
|
// The results of ListOffsets or OffsetForLeaderEpoch for an individual broker.
|
|
type loadedOffsets struct {
|
|
broker int32
|
|
loaded []loadedOffset
|
|
loadType listOrEpochLoadType
|
|
}
|
|
|
|
func (l *loadedOffsets) add(a loadedOffset) { l.loaded = append(l.loaded, a) }
|
|
func (l *loadedOffsets) addAll(as []loadedOffset) loadedOffsets {
|
|
l.loaded = append(l.loaded, as...)
|
|
return *l
|
|
}
|
|
|
|
func (cl *Client) listOffsetsForBrokerLoad(ctx context.Context, broker *broker, load offsetLoadMap, tps *topicsPartitions, results chan<- loadedOffsets) {
|
|
loaded := loadedOffsets{broker: broker.meta.NodeID, loadType: loadTypeList}
|
|
|
|
req1, req2 := load.buildListReq(cl.cfg.isolationLevel)
|
|
var (
|
|
wg sync.WaitGroup
|
|
kresp2 kmsg.Response
|
|
err2 error
|
|
)
|
|
if req2 != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
kresp2, err2 = broker.waitResp(ctx, req2)
|
|
}()
|
|
}
|
|
kresp, err := broker.waitResp(ctx, req1)
|
|
wg.Wait()
|
|
if err != nil || err2 != nil {
|
|
results <- loaded.addAll(load.errToLoaded(err))
|
|
return
|
|
}
|
|
|
|
topics := tps.load()
|
|
resp := kresp.(*kmsg.ListOffsetsResponse)
|
|
|
|
// If we issued a second req to check that an exact offset is in
|
|
// bounds, then regrettably for safety, we have to ensure that the
|
|
// shapes of both responses match, and the topic & partition at each
|
|
// index matches. Anything that does not match is skipped (and would be
|
|
// a bug from Kafka), and we at the end return UnknownTopicOrPartition.
|
|
var resp2 *kmsg.ListOffsetsResponse
|
|
if req2 != nil {
|
|
resp2 = kresp2.(*kmsg.ListOffsetsResponse)
|
|
for _, r := range []*kmsg.ListOffsetsResponse{
|
|
resp,
|
|
resp2,
|
|
} {
|
|
ts := r.Topics
|
|
sort.Slice(ts, func(i, j int) bool {
|
|
return ts[i].Topic < ts[j].Topic
|
|
})
|
|
for i := range ts {
|
|
ps := ts[i].Partitions
|
|
sort.Slice(ps, func(i, j int) bool {
|
|
return ps[i].Partition < ps[j].Partition
|
|
})
|
|
}
|
|
}
|
|
|
|
lt := resp.Topics
|
|
rt := resp2.Topics
|
|
lkeept := lt[:0]
|
|
rkeept := rt[:0]
|
|
// Over each response, we only keep the topic if the topics match.
|
|
for len(lt) > 0 && len(rt) > 0 {
|
|
if lt[0].Topic < rt[0].Topic {
|
|
lt = lt[1:]
|
|
continue
|
|
}
|
|
if rt[0].Topic < lt[0].Topic {
|
|
rt = rt[1:]
|
|
continue
|
|
}
|
|
// As well, for topics that match, we only keep
|
|
// partitions that match. In this case, we also want
|
|
// both partitions to be error free, otherwise we keep
|
|
// an error on both. If one has old style offsets,
|
|
// both must.
|
|
lp := lt[0].Partitions
|
|
rp := rt[0].Partitions
|
|
lkeepp := lp[:0]
|
|
rkeepp := rp[:0]
|
|
for len(lp) > 0 && len(rp) > 0 {
|
|
if lp[0].Partition < rp[0].Partition {
|
|
lp = lp[1:]
|
|
continue
|
|
}
|
|
if rp[0].Partition < lp[0].Partition {
|
|
rp = rp[1:]
|
|
continue
|
|
}
|
|
if len(lp[0].OldStyleOffsets) > 0 && len(rp[0].OldStyleOffsets) == 0 ||
|
|
len(lp[0].OldStyleOffsets) == 0 && len(rp[0].OldStyleOffsets) > 0 {
|
|
lp = lp[1:]
|
|
rp = rp[1:]
|
|
continue
|
|
}
|
|
if lp[0].ErrorCode != 0 {
|
|
rp[0].ErrorCode = lp[0].ErrorCode
|
|
} else if rp[0].ErrorCode != 0 {
|
|
lp[0].ErrorCode = rp[0].ErrorCode
|
|
}
|
|
lkeepp = append(lkeepp, lp[0])
|
|
rkeepp = append(rkeepp, rp[0])
|
|
lp = lp[1:]
|
|
rp = rp[1:]
|
|
}
|
|
// Now we update the partitions in the topic we are
|
|
// keeping, and keep our topic.
|
|
lt[0].Partitions = lkeepp
|
|
rt[0].Partitions = rkeepp
|
|
lkeept = append(lkeept, lt[0])
|
|
rkeept = append(rkeept, rt[0])
|
|
lt = lt[1:]
|
|
rt = rt[1:]
|
|
}
|
|
// Finally, update each response with the topics we kept. The
|
|
// shapes and indices are the same.
|
|
resp.Topics = lkeept
|
|
resp2.Topics = rkeept
|
|
}
|
|
|
|
poffset := func(p *kmsg.ListOffsetsResponseTopicPartition) int64 {
|
|
offset := p.Offset
|
|
if len(p.OldStyleOffsets) > 0 {
|
|
offset = p.OldStyleOffsets[0] // list offsets v0
|
|
}
|
|
return offset
|
|
}
|
|
|
|
for i, rTopic := range resp.Topics {
|
|
topic := rTopic.Topic
|
|
loadParts, ok := load[topic]
|
|
if !ok {
|
|
continue // should not happen: kafka replied with something we did not ask for
|
|
}
|
|
|
|
topicPartitions := topics.loadTopic(topic) // must be non-nil at this point
|
|
for j, rPartition := range rTopic.Partitions {
|
|
partition := rPartition.Partition
|
|
loadPart, ok := loadParts[partition]
|
|
if !ok {
|
|
continue // should not happen: kafka replied with something we did not ask for
|
|
}
|
|
|
|
if err := kerr.ErrorForCode(rPartition.ErrorCode); err != nil {
|
|
loaded.add(loadedOffset{
|
|
topic: topic,
|
|
partition: partition,
|
|
err: err,
|
|
request: loadPart,
|
|
})
|
|
continue // partition err: handled in results
|
|
}
|
|
|
|
if partition < 0 || partition >= int32(len(topicPartitions.partitions)) {
|
|
continue // should not happen: we have not seen this partition from a metadata response
|
|
}
|
|
topicPartition := topicPartitions.partitions[partition]
|
|
|
|
delete(loadParts, partition)
|
|
if len(loadParts) == 0 {
|
|
delete(load, topic)
|
|
}
|
|
|
|
offset := poffset(&rPartition)
|
|
end := func() int64 { return poffset(&resp2.Topics[i].Partitions[j]) }
|
|
|
|
// We ensured the resp2 shape is as we want and has no
|
|
// error, so resp2 lookups are safe.
|
|
if loadPart.afterMilli {
|
|
// If after a milli, if the milli is after the
|
|
// end of a partition, the offset is -1. We use
|
|
// our end offset request: anything after the
|
|
// end offset *now* is after our milli.
|
|
if offset == -1 {
|
|
offset = end()
|
|
}
|
|
} else if loadPart.at >= 0 {
|
|
// If an exact offset, we listed start and end.
|
|
// We validate the offset is within bounds.
|
|
end := end()
|
|
want := loadPart.at + loadPart.relative
|
|
if want >= offset {
|
|
offset = want
|
|
}
|
|
if want >= end {
|
|
offset = end
|
|
}
|
|
} else if loadPart.at == -2 && loadPart.relative > 0 {
|
|
// Relative to the start: both start & end were
|
|
// issued, and we bound to the end.
|
|
offset += loadPart.relative
|
|
if end := end(); offset >= end {
|
|
offset = end
|
|
}
|
|
} else if loadPart.at == -1 && loadPart.relative < 0 {
|
|
// Relative to the end: both start & end were
|
|
// issued, offset is currently the start, so we
|
|
// set to the end and then bound to the start.
|
|
start := offset
|
|
offset = end()
|
|
offset += loadPart.relative
|
|
if offset <= start {
|
|
offset = start
|
|
}
|
|
}
|
|
if offset < 0 {
|
|
offset = 0 // sanity
|
|
}
|
|
|
|
loaded.add(loadedOffset{
|
|
topic: topic,
|
|
partition: partition,
|
|
cursor: topicPartition.cursor,
|
|
offset: offset,
|
|
leaderEpoch: rPartition.LeaderEpoch,
|
|
request: loadPart,
|
|
})
|
|
}
|
|
}
|
|
|
|
results <- loaded.addAll(load.errToLoaded(kerr.UnknownTopicOrPartition))
|
|
}
|
|
|
|
func (*Client) loadEpochsForBrokerLoad(ctx context.Context, broker *broker, load offsetLoadMap, tps *topicsPartitions, results chan<- loadedOffsets) {
|
|
loaded := loadedOffsets{broker: broker.meta.NodeID, loadType: loadTypeEpoch}
|
|
|
|
kresp, err := broker.waitResp(ctx, load.buildEpochReq())
|
|
if err != nil {
|
|
results <- loaded.addAll(load.errToLoaded(err))
|
|
return
|
|
}
|
|
|
|
// If the version is < 2, we are speaking to an old broker. We should
|
|
// not have an old version, but we could have spoken to a new broker
|
|
// first then an old broker in the middle of a broker roll. For now, we
|
|
// will just loop retrying until the broker is upgraded.
|
|
|
|
topics := tps.load()
|
|
resp := kresp.(*kmsg.OffsetForLeaderEpochResponse)
|
|
for _, rTopic := range resp.Topics {
|
|
topic := rTopic.Topic
|
|
loadParts, ok := load[topic]
|
|
if !ok {
|
|
continue // should not happen: kafka replied with something we did not ask for
|
|
}
|
|
|
|
topicPartitions := topics.loadTopic(topic) // must be non-nil at this point
|
|
for _, rPartition := range rTopic.Partitions {
|
|
partition := rPartition.Partition
|
|
loadPart, ok := loadParts[partition]
|
|
if !ok {
|
|
continue // should not happen: kafka replied with something we did not ask for
|
|
}
|
|
|
|
if err := kerr.ErrorForCode(rPartition.ErrorCode); err != nil {
|
|
loaded.add(loadedOffset{
|
|
topic: topic,
|
|
partition: partition,
|
|
err: err,
|
|
request: loadPart,
|
|
})
|
|
continue // partition err: handled in results
|
|
}
|
|
|
|
if partition < 0 || partition >= int32(len(topicPartitions.partitions)) {
|
|
continue // should not happen: we have not seen this partition from a metadata response
|
|
}
|
|
topicPartition := topicPartitions.partitions[partition]
|
|
|
|
delete(loadParts, partition)
|
|
if len(loadParts) == 0 {
|
|
delete(load, topic)
|
|
}
|
|
|
|
// Epoch loading never uses noReset nor afterMilli;
|
|
// this at is the offset we wanted to consume and are
|
|
// validating.
|
|
offset := loadPart.at
|
|
var err error
|
|
if rPartition.EndOffset < offset {
|
|
err = &ErrDataLoss{topic, partition, offset, loadPart.epoch, rPartition.EndOffset, rPartition.LeaderEpoch}
|
|
offset = rPartition.EndOffset
|
|
}
|
|
|
|
loaded.add(loadedOffset{
|
|
topic: topic,
|
|
partition: partition,
|
|
cursor: topicPartition.cursor,
|
|
offset: offset,
|
|
leaderEpoch: rPartition.LeaderEpoch,
|
|
err: err,
|
|
request: loadPart,
|
|
})
|
|
}
|
|
}
|
|
|
|
results <- loaded.addAll(load.errToLoaded(kerr.UnknownTopicOrPartition))
|
|
}
|
|
|
|
// In general this returns one request, but if the user is using exact offsets
|
|
// rather than start/end, then we issue both the start and end requests to
|
|
// ensure the user's requested offset is within bounds.
|
|
func (o offsetLoadMap) buildListReq(isolationLevel int8) (r1, r2 *kmsg.ListOffsetsRequest) {
|
|
r1 = kmsg.NewPtrListOffsetsRequest()
|
|
r1.ReplicaID = -1
|
|
r1.IsolationLevel = isolationLevel
|
|
r1.Topics = make([]kmsg.ListOffsetsRequestTopic, 0, len(o))
|
|
var createEnd bool
|
|
for topic, partitions := range o {
|
|
parts := make([]kmsg.ListOffsetsRequestTopicPartition, 0, len(partitions))
|
|
for partition, offset := range partitions {
|
|
// If this is a milli request, we issue two lists: if
|
|
// our milli is after the end of a partition, we get no
|
|
// offset back and we want to know the start offset
|
|
// (since it will be after our milli).
|
|
//
|
|
// If we are using an exact offset request, we issue
|
|
// the start and end so that we can bound the exact
|
|
// offset to being within that range.
|
|
//
|
|
// If we are using a relative offset, we potentially
|
|
// issue the end request because relative may shift us
|
|
// too far in the other direction.
|
|
timestamp := offset.at
|
|
if offset.afterMilli {
|
|
createEnd = true
|
|
} else if timestamp >= 0 || timestamp == -2 && offset.relative > 0 || timestamp == -1 && offset.relative < 0 {
|
|
timestamp = -2
|
|
createEnd = true
|
|
}
|
|
p := kmsg.NewListOffsetsRequestTopicPartition()
|
|
p.Partition = partition
|
|
p.CurrentLeaderEpoch = offset.currentEpoch // KIP-320
|
|
p.Timestamp = timestamp
|
|
p.MaxNumOffsets = 1
|
|
|
|
parts = append(parts, p)
|
|
}
|
|
t := kmsg.NewListOffsetsRequestTopic()
|
|
t.Topic = topic
|
|
t.Partitions = parts
|
|
r1.Topics = append(r1.Topics, t)
|
|
}
|
|
|
|
if createEnd {
|
|
r2 = kmsg.NewPtrListOffsetsRequest()
|
|
*r2 = *r1
|
|
r2.Topics = slices.Clone(r1.Topics)
|
|
for i := range r1.Topics {
|
|
l := &r2.Topics[i]
|
|
r := &r1.Topics[i]
|
|
*l = *r
|
|
l.Partitions = slices.Clone(r.Partitions)
|
|
for i := range l.Partitions {
|
|
l.Partitions[i].Timestamp = -1
|
|
}
|
|
}
|
|
}
|
|
|
|
return r1, r2
|
|
}
|
|
|
|
func (o offsetLoadMap) buildEpochReq() *kmsg.OffsetForLeaderEpochRequest {
|
|
req := kmsg.NewPtrOffsetForLeaderEpochRequest()
|
|
req.ReplicaID = -1
|
|
req.Topics = make([]kmsg.OffsetForLeaderEpochRequestTopic, 0, len(o))
|
|
for topic, partitions := range o {
|
|
parts := make([]kmsg.OffsetForLeaderEpochRequestTopicPartition, 0, len(partitions))
|
|
for partition, offset := range partitions {
|
|
p := kmsg.NewOffsetForLeaderEpochRequestTopicPartition()
|
|
p.Partition = partition
|
|
p.CurrentLeaderEpoch = offset.currentEpoch
|
|
p.LeaderEpoch = offset.epoch
|
|
parts = append(parts, p)
|
|
}
|
|
t := kmsg.NewOffsetForLeaderEpochRequestTopic()
|
|
t.Topic = topic
|
|
t.Partitions = parts
|
|
req.Topics = append(req.Topics, t)
|
|
}
|
|
return req
|
|
}
|