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

Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
2026-01-07 23:39:49 +00:00

1053 lines
30 KiB
Go

package kgo
import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/twmb/franz-go/pkg/kerr"
)
type metawait struct {
mu sync.Mutex
c *sync.Cond
lastUpdate time.Time
}
func (m *metawait) init() { m.c = sync.NewCond(&m.mu) }
func (m *metawait) signal() {
m.mu.Lock()
m.lastUpdate = time.Now()
m.mu.Unlock()
m.c.Broadcast()
}
// ForceMetadataRefresh triggers the client to update the metadata that is
// currently used for producing & consuming.
//
// Internally, the client already properly triggers metadata updates whenever a
// partition is discovered to be out of date (leader moved, epoch is old, etc).
// However, when partitions are added to a topic through a CreatePartitions
// request, it may take up to MetadataMaxAge for the new partitions to be
// discovered. In this case, you may want to forcefully refresh metadata
// manually to discover these new partitions sooner.
func (cl *Client) ForceMetadataRefresh() {
cl.triggerUpdateMetadataNow("from user ForceMetadataRefresh")
}
// PartitionLeader returns the given topic partition's leader, leader epoch and
// load error. This returns -1, -1, nil if the partition has not been loaded.
func (cl *Client) PartitionLeader(topic string, partition int32) (leader, leaderEpoch int32, err error) {
if partition < 0 {
return -1, -1, errors.New("invalid negative partition")
}
var t *topicPartitions
m := cl.producer.topics.load()
if len(m) > 0 {
t = m[topic]
}
if t == nil {
if cl.consumer.g != nil {
if m = cl.consumer.g.tps.load(); len(m) > 0 {
t = m[topic]
}
} else if cl.consumer.d != nil {
if m = cl.consumer.d.tps.load(); len(m) > 0 {
t = m[topic]
}
}
if t == nil {
return -1, -1, nil
}
}
tv := t.load()
if len(tv.partitions) <= int(partition) {
return -1, -1, tv.loadErr
}
p := tv.partitions[partition]
return p.leader, p.leaderEpoch, p.loadErr
}
var noid2t = make(map[[16]byte]string)
func (cl *Client) id2tMap() map[[16]byte]string {
v := cl.id2t.Load()
if v == nil {
return noid2t
}
m := v.(map[[16]byte]string)
if m == nil {
return noid2t
}
return m
}
// waitmeta returns immediately if metadata was updated within the last second,
// otherwise this waits for up to wait for a metadata update to complete.
func (cl *Client) waitmeta(ctx context.Context, wait time.Duration, why string) {
now := time.Now()
cl.metawait.mu.Lock()
if now.Sub(cl.metawait.lastUpdate) < cl.cfg.metadataMinAge {
cl.metawait.mu.Unlock()
return
}
cl.metawait.mu.Unlock()
cl.triggerUpdateMetadataNow(why)
quit := false
done := make(chan struct{})
timeout := time.NewTimer(wait)
defer timeout.Stop()
go func() {
defer close(done)
cl.metawait.mu.Lock()
defer cl.metawait.mu.Unlock()
for !quit {
if now.Sub(cl.metawait.lastUpdate) < cl.cfg.metadataMinAge {
return
}
cl.metawait.c.Wait()
}
}()
select {
case <-done:
return
case <-timeout.C:
case <-ctx.Done():
case <-cl.ctx.Done():
}
cl.metawait.mu.Lock()
quit = true
cl.metawait.mu.Unlock()
cl.metawait.c.Broadcast()
}
func (cl *Client) triggerUpdateMetadata(must bool, why string) bool {
if !must {
cl.metawait.mu.Lock()
defer cl.metawait.mu.Unlock()
if time.Since(cl.metawait.lastUpdate) < cl.cfg.metadataMinAge {
return false
}
}
select {
case cl.updateMetadataCh <- why:
default:
}
return true
}
func (cl *Client) triggerUpdateMetadataNow(why string) {
select {
case cl.updateMetadataNowCh <- why:
default:
}
}
func (cl *Client) blockingMetadataFn(fn func()) {
var wg sync.WaitGroup
wg.Add(1)
waitfn := func() {
defer wg.Done()
fn()
}
select {
case cl.blockingMetadataFnCh <- waitfn:
wg.Wait()
case <-cl.ctx.Done():
}
}
// updateMetadataLoop updates metadata whenever the update ticker ticks,
// or whenever deliberately triggered.
func (cl *Client) updateMetadataLoop() {
defer close(cl.metadone)
var consecutiveErrors int
var lastAt time.Time
ticker := time.NewTicker(cl.cfg.metadataMaxAge)
defer ticker.Stop()
loop:
for {
var now bool
select {
case <-cl.ctx.Done():
return
case <-ticker.C:
// We do not log on the standard update case.
case why := <-cl.updateMetadataCh:
cl.cfg.logger.Log(LogLevelInfo, "metadata update triggered", "why", why)
case why := <-cl.updateMetadataNowCh:
cl.cfg.logger.Log(LogLevelInfo, "immediate metadata update triggered", "why", why)
now = true
case fn := <-cl.blockingMetadataFnCh:
fn()
continue loop
}
var nowTries int
start:
nowTries++
if !now {
if wait := cl.cfg.metadataMinAge - time.Since(lastAt); wait > 0 {
timer := time.NewTimer(wait)
prewait:
select {
case <-cl.ctx.Done():
timer.Stop()
return
case why := <-cl.updateMetadataNowCh:
timer.Stop()
cl.cfg.logger.Log(LogLevelInfo, "immediate metadata update triggered, bypassing normal wait", "why", why)
case <-timer.C:
case fn := <-cl.blockingMetadataFnCh:
fn()
goto prewait
}
}
}
// Even with an "update now", we sleep just a bit to allow some
// potential pile on now triggers.
time.Sleep(time.Until(lastAt.Add(10 * time.Millisecond)))
// Drain any refires that occurred during our waiting.
out:
for {
select {
case <-cl.updateMetadataCh:
case <-cl.updateMetadataNowCh:
case fn := <-cl.blockingMetadataFnCh:
fn()
default:
break out
}
}
retryWhy, err := cl.updateMetadata()
lastAt = time.Now()
if retryWhy != nil || err != nil {
// If err is non-nil, the metadata request failed
// itself and already retried 3x; we do not loop more.
//
// If err is nil, the a topic or partition had a load
// error and is perhaps still being created. We retry a
// few more times to give Kafka a chance to figure
// things out. By default this will put us at 2s of
// looping+waiting (250ms per wait, 8x), and if things
// still fail we will fall into the slower update below
// which waits (default) 5s between tries.
if now && err == nil && nowTries < 8 {
wait := min(cl.cfg.metadataMinAge, 250*time.Millisecond)
cl.cfg.logger.Log(LogLevelDebug, "immediate metadata update had inner errors, re-updating",
"errors", retryWhy.reason(""),
"update_after", wait,
)
timer := time.NewTimer(wait)
quickbackoff:
select {
case <-cl.ctx.Done():
timer.Stop()
return
case <-timer.C:
case fn := <-cl.blockingMetadataFnCh:
fn()
goto quickbackoff
}
goto start
}
if err != nil {
cl.triggerUpdateMetadata(true, fmt.Sprintf("re-updating metadata due to err: %s", err))
} else {
cl.triggerUpdateMetadata(true, retryWhy.reason("re-updating due to inner errors"))
}
}
if err == nil {
cl.metawait.signal()
cl.consumer.doOnMetadataUpdate()
consecutiveErrors = 0
continue
}
consecutiveErrors++
// We sleep a bit in case the max metadata age is very small;
// typically this sleep is inconsequential.
after := time.NewTimer(cl.cfg.retryBackoff(consecutiveErrors))
backoff:
select {
case <-cl.ctx.Done():
after.Stop()
return
case <-after.C:
case fn := <-cl.blockingMetadataFnCh:
fn()
goto backoff
}
}
}
var errMissingTopic = errors.New("topic_missing")
// Updates all producer and consumer partition data, returning whether a new
// update needs scheduling or if an error occurred.
//
// The producer and consumer use different topic maps and underlying
// topicPartitionsData pointers, but we update those underlying pointers
// equally.
func (cl *Client) updateMetadata() (retryWhy multiUpdateWhy, err error) {
var (
tpsProducerLoad = cl.producer.topics.load()
tpsConsumer *topicsPartitions
groupExternal *groupExternal
all = cl.cfg.regex
reqTopics []string
)
c := &cl.consumer
switch {
case c.g != nil:
tpsConsumer = c.g.tps
groupExternal = c.g.loadExternal()
case c.d != nil:
tpsConsumer = c.d.tps
}
if !all {
reqTopicsSet := make(map[string]struct{})
for _, m := range []map[string]*topicPartitions{
tpsProducerLoad,
tpsConsumer.load(),
} {
for topic := range m {
reqTopicsSet[topic] = struct{}{}
}
}
groupExternal.eachTopic(func(t string) {
reqTopicsSet[t] = struct{}{}
})
reqTopics = make([]string, 0, len(reqTopicsSet))
for topic := range reqTopicsSet {
reqTopics = append(reqTopics, topic)
}
}
// If the user has auto topic creation while producing AND is consuming
// via regex, we need to send a separate metadata request with unknown
// produce topics before our standard metadata request. The standard
// metadata request is send with a nil Topics field (requesting all),
// which prevents us from ever creating the topics.
var unknownCreateResp map[string]*metadataTopic
if all && cl.cfg.allowAutoTopicCreation {
cl.producer.unknownTopicsMu.Lock()
if len(cl.producer.unknownTopics) > 0 {
unknownTopics := make([]string, 0, len(cl.producer.unknownTopics))
for unknown := range cl.producer.unknownTopics {
unknownTopics = append(unknownTopics, unknown)
}
var err error
unknownCreateResp, err = cl.fetchTopicMetadata(false, unknownTopics)
if err != nil {
// We bump all produce topics even though we
// only explicitly requested unknown ones; this
// is a general request failure and we want to
// note it (also this is simpler...).
cl.bumpMetadataFailForTopics(
tpsProducerLoad,
err,
)
}
}
cl.producer.unknownTopicsMu.Unlock()
}
latest, err := cl.fetchTopicMetadata(all, reqTopics)
if err != nil {
cl.bumpMetadataFailForTopics( // bump load failures for all topics
tpsProducerLoad,
err,
)
return nil, err
}
groupExternal.updateLatest(latest)
// If regex consuming AND we issued a metadata request to forcefully
// create topics, we merge any topics missing into the all-request from
// the create-request. It is possible we want to keep failed creation
// errors.
for t, mt := range unknownCreateResp {
if _, ok := latest[t]; !ok {
latest[t] = mt
}
}
// If we are consuming with regex and fetched all topics, the metadata
// may have returned topics the consumer is not yet tracking. We ensure
// that we will store the topics at the end of our metadata update.
tpsConsumerLoad := tpsConsumer.load()
if all {
allTopics := make([]string, 0, len(latest))
for topic, mt := range latest {
// loadErr should only be non-nil when requesting all
// topics if this is with auto-topic-creation && the
// creation failed. That is, we should not consume the
// topic since we just tried creating it and creating
// it failed.
if mt.loadErr == nil {
allTopics = append(allTopics, topic)
}
}
// We filter out topics will not match any of our regex's.
// This ensures that the `tps` field does not contain topics
// we will never use (the client works with misc. topics in
// there, but it's better to avoid it -- and allows us to use
// `tps` in GetConsumeTopics).
allTopics = c.filterMetadataAllTopics(allTopics)
tpsConsumerLoad = tpsConsumer.ensureTopics(allTopics)
defer tpsConsumer.storeData(tpsConsumerLoad)
// For regex consuming, if a topic is not returned in the
// response and for at least missingTopicDelete from when we
// first discovered it, we assume the topic has been deleted
// and purge it. We allow for missingTopicDelete because (in
// testing locally) Kafka can originally broadcast a newly
// created topic exists and then fail to broadcast that info
// again for a while.
var purgeTopics []string
for topic, tps := range tpsConsumerLoad {
if _, ok := latest[topic]; !ok {
if td := tps.load(); td.when != 0 && time.Since(time.Unix(td.when, 0)) > cl.cfg.missingTopicDelete {
purgeTopics = append(purgeTopics, td.topic)
} else {
retryWhy.add(topic, -1, errMissingTopic)
}
}
}
if len(purgeTopics) > 0 {
// We have to `go` because Purge issues a blocking
// metadata fn; this will wait for our current
// execution to finish then purge.
cl.cfg.logger.Log(LogLevelInfo, "regex consumer purging topics that were previously consumed because they are missing in a metadata response, we are assuming they are deleted", "topics", purgeTopics)
go cl.PurgeTopicsFromClient(purgeTopics...)
}
}
css := &consumerSessionStopper{cl: cl}
defer css.maybeRestart()
var missingProduceTopics []*topicPartitions
for _, m := range []struct {
priors map[string]*topicPartitions
isProduce bool
}{
{tpsProducerLoad, true},
{tpsConsumerLoad, false},
} {
for topic, priorParts := range m.priors {
newParts, exists := latest[topic]
if !exists {
if m.isProduce {
missingProduceTopics = append(missingProduceTopics, priorParts)
}
continue
}
cl.mergeTopicPartitions(
topic,
priorParts,
newParts,
m.isProduce,
css,
&retryWhy,
)
}
}
// For all produce topics that were missing, we want to bump their
// retries that a failure happened. However, if we are regex consuming,
// then it is possible in a rare scenario for the broker to not return
// a topic that actually does exist and that we previously received a
// metadata response for. This is handled above for consuming, we now
// handle it the same way for consuming.
if len(missingProduceTopics) > 0 {
var bumpFail []string
for _, tps := range missingProduceTopics {
if all {
if td := tps.load(); td.when != 0 && time.Since(time.Unix(td.when, 0)) > cl.cfg.missingTopicDelete {
bumpFail = append(bumpFail, td.topic)
} else {
retryWhy.add(td.topic, -1, errMissingTopic)
}
} else {
bumpFail = append(bumpFail, tps.load().topic)
}
}
if len(bumpFail) > 0 {
cl.bumpMetadataFailForTopics(
tpsProducerLoad,
fmt.Errorf("metadata request did not return topics: %v", bumpFail),
bumpFail...,
)
}
}
return retryWhy, nil
}
// We use a special structure to repesent metadata before we *actually* convert
// it to topicPartitionsData. This helps avoid any pointer reuse problems
// because we want to keep the client's producer and consumer maps completely
// independent. If we just returned map[string]*topicPartitionsData, we could
// end up in some really weird pointer reuse scenario that ultimately results
// in a bug.
//
// See #190 for more details, as well as the commit message introducing this.
type metadataTopic struct {
loadErr error
isInternal bool
topic string
id [16]byte
partitions []metadataPartition
}
func (mt *metadataTopic) newPartitions(cl *Client, isProduce bool) *topicPartitionsData {
n := len(mt.partitions)
ps := &topicPartitionsData{
loadErr: mt.loadErr,
isInternal: mt.isInternal,
partitions: make([]*topicPartition, 0, n),
writablePartitions: make([]*topicPartition, 0, n),
topic: mt.topic,
id: mt.id,
when: time.Now().Unix(),
}
for i := range mt.partitions {
p := mt.partitions[i].newPartition(cl, isProduce)
ps.partitions = append(ps.partitions, p)
if p.loadErr == nil {
ps.writablePartitions = append(ps.writablePartitions, p)
}
}
return ps
}
type metadataPartition struct {
topic string
topicID [16]byte
partition int32
loadErr int16
leader int32
leaderEpoch int32
sns sinkAndSource
}
func (mp metadataPartition) newPartition(cl *Client, isProduce bool) *topicPartition {
td := topicPartitionData{
leader: mp.leader,
leaderEpoch: mp.leaderEpoch,
}
p := &topicPartition{
loadErr: kerr.ErrorForCode(mp.loadErr),
topicPartitionData: td,
}
if isProduce {
p.records = &recBuf{
cl: cl,
topic: mp.topic,
topicID: mp.topicID,
partition: mp.partition,
maxRecordBatchBytes: cl.maxRecordBatchBytesForTopic(mp.topic),
recBufsIdx: -1,
failing: mp.loadErr != 0,
sink: mp.sns.sink,
topicPartitionData: td,
lastAckedOffset: -1,
}
} else {
p.cursor = &cursor{
topic: mp.topic,
topicID: mp.topicID,
partition: mp.partition,
keepControl: cl.cfg.keepControl,
cursorsIdx: -1,
source: mp.sns.source,
topicPartitionData: td,
cursorOffset: cursorOffset{
offset: -1, // required to not consume until needed
lastConsumedEpoch: -1, // required sentinel
},
}
}
return p
}
// fetchTopicMetadata fetches metadata for all reqTopics and returns new
// topicPartitionsData for each topic.
func (cl *Client) fetchTopicMetadata(all bool, reqTopics []string) (map[string]*metadataTopic, error) {
_, meta, err := cl.fetchMetadataForTopics(cl.ctx, all, reqTopics, nil)
if err != nil {
return nil, err
}
topics := make(map[string]*metadataTopic, len(meta.Topics))
id2t := make(map[[16]byte]string, len(meta.Topics))
defer cl.id2t.Store(id2t)
// Even if metadata returns a leader epoch, we do not use it unless we
// can validate it per OffsetForLeaderEpoch. Some brokers may have an
// odd set of support.
useLeaderEpoch := cl.supportsOffsetForLeaderEpoch()
for i := range meta.Topics {
topicMeta := &meta.Topics[i]
if topicMeta.Topic == nil {
cl.cfg.logger.Log(LogLevelWarn, "metadata response contained nil topic name even though we did not request with topic IDs, skipping")
continue
}
topic := *topicMeta.Topic
id2t[topicMeta.TopicID] = topic
mt := &metadataTopic{
loadErr: kerr.ErrorForCode(topicMeta.ErrorCode),
isInternal: topicMeta.IsInternal,
topic: topic,
id: topicMeta.TopicID,
partitions: make([]metadataPartition, 0, len(topicMeta.Partitions)),
}
topics[topic] = mt
if mt.loadErr != nil {
continue
}
// This 249 limit is in Kafka itself, we copy it here to rely on it while producing.
if len(topic) > 249 {
mt.loadErr = fmt.Errorf("invalid long topic name of (len %d) greater than max allowed 249", len(topic))
continue
}
// Kafka partitions are strictly increasing from 0. We enforce
// that here; if any partition is missing, we consider this
// topic a load failure.
sort.Slice(topicMeta.Partitions, func(i, j int) bool {
return topicMeta.Partitions[i].Partition < topicMeta.Partitions[j].Partition
})
for i := range topicMeta.Partitions {
if got := topicMeta.Partitions[i].Partition; got != int32(i) {
mt.loadErr = fmt.Errorf("kafka did not reply with a comprensive set of partitions for a topic; we expected partition %d but saw %d", i, got)
break
}
}
if mt.loadErr != nil {
continue
}
for i := range topicMeta.Partitions {
partMeta := &topicMeta.Partitions[i]
leaderEpoch := partMeta.LeaderEpoch
if meta.Version < 7 || !useLeaderEpoch {
leaderEpoch = -1
}
mp := metadataPartition{
topic: topic,
topicID: topicMeta.TopicID,
partition: partMeta.Partition,
loadErr: partMeta.ErrorCode,
leader: partMeta.Leader,
leaderEpoch: leaderEpoch,
}
if mp.loadErr != 0 {
mp.leader = unknownSeedID(0) // ensure every records & cursor can use a sink or source
}
cl.sinksAndSourcesMu.Lock()
sns, exists := cl.sinksAndSources[mp.leader]
if !exists {
sns = sinkAndSource{
sink: cl.newSink(mp.leader),
source: cl.newSource(mp.leader),
}
cl.sinksAndSources[mp.leader] = sns
}
for _, replica := range partMeta.Replicas {
if replica < 0 {
continue
}
if _, exists = cl.sinksAndSources[replica]; !exists {
cl.sinksAndSources[replica] = sinkAndSource{
sink: cl.newSink(replica),
source: cl.newSource(replica),
}
}
}
cl.sinksAndSourcesMu.Unlock()
mp.sns = sns
mt.partitions = append(mt.partitions, mp)
}
}
return topics, nil
}
// mergeTopicPartitions merges a new topicPartition into an old and returns
// whether the metadata update that caused this merge needs to be retried.
//
// Retries are necessary if the topic or any partition has a retryable error.
func (cl *Client) mergeTopicPartitions(
topic string,
l *topicPartitions,
mt *metadataTopic,
isProduce bool,
css *consumerSessionStopper,
retryWhy *multiUpdateWhy,
) {
lv := *l.load() // copy so our field writes do not collide with reads
r := mt.newPartitions(cl, isProduce)
// Producers must store the update through a special function that
// manages unknown topic waiting, whereas consumers can just simply
// store the update.
if isProduce {
hadPartitions := len(lv.partitions) != 0
defer func() { cl.storePartitionsUpdate(topic, l, &lv, hadPartitions) }()
} else {
defer l.v.Store(&lv)
}
lv.loadErr = r.loadErr
lv.isInternal = r.isInternal
lv.topic = r.topic
lv.id = r.id
if lv.when == 0 {
lv.when = r.when
}
// If the load had an error for the entire topic, we set the load error
// but keep our stale partition information. For anything being
// produced, we bump the respective error or fail everything. There is
// nothing to be done in a consumer.
if r.loadErr != nil {
if isProduce {
for _, topicPartition := range lv.partitions {
topicPartition.records.bumpRepeatedLoadErr(lv.loadErr)
}
} else if !kerr.IsRetriable(r.loadErr) || cl.cfg.keepRetryableFetchErrors {
cl.consumer.addFakeReadyForDraining(topic, -1, r.loadErr, "metadata refresh has a load error on this entire topic")
}
retryWhy.add(topic, -1, r.loadErr)
return
}
// Before the atomic update, we keep the latest partitions / writable
// partitions. All updates happen in r's slices, and we keep the
// results and store them in lv.
defer func() {
lv.partitions = r.partitions
lv.writablePartitions = r.writablePartitions
}()
// We should have no deleted partitions, but there are two cases where
// we could.
//
// 1) an admin added partitions, we saw, then we re-fetched metadata
// from an out of date broker that did not have the new partitions
//
// 2) a topic was deleted and recreated with fewer partitions
//
// Both of these scenarios should be rare to non-existent, and we do
// nothing if we encounter them.
// Migrating topicPartitions is a little tricky because we have to
// worry about underlying pointers that may currently be loaded.
for part, oldTP := range lv.partitions {
exists := part < len(r.partitions)
if !exists {
// This is the "deleted" case; see the comment above.
//
// We need to keep the partition around. For producing,
// the partition could be loaded and a record could be
// added to it after we bump the load error. For
// consuming, the partition is part of a group or part
// of what was loaded for direct consuming.
//
// We only clear a partition if it is purged from the
// client (which can happen automatically for consumers
// if the user opted into ConsumeRecreatedTopics).
dup := *oldTP
newTP := &dup
newTP.loadErr = errMissingMetadataPartition
r.partitions = append(r.partitions, newTP)
cl.cfg.logger.Log(LogLevelDebug, "metadata update is missing partition in topic, we are keeping the partition around for safety -- use PurgeTopicsFromClient if you wish to remove the topic",
"topic", topic,
"partition", part,
)
if isProduce {
oldTP.records.bumpRepeatedLoadErr(errMissingMetadataPartition)
}
retryWhy.add(topic, int32(part), errMissingMetadataPartition)
continue
}
newTP := r.partitions[part]
// Like above for the entire topic, an individual partition
// can have a load error. Unlike for the topic, individual
// partition errors are always retryable.
//
// If the load errored, we keep all old information minus the
// load error itself (the new load will have no information).
if newTP.loadErr != nil {
err := newTP.loadErr
*newTP = *oldTP
newTP.loadErr = err
if isProduce {
newTP.records.bumpRepeatedLoadErr(newTP.loadErr)
} else if !kerr.IsRetriable(newTP.loadErr) || cl.cfg.keepRetryableFetchErrors {
cl.consumer.addFakeReadyForDraining(topic, int32(part), newTP.loadErr, "metadata refresh has a load error on this partition")
}
retryWhy.add(topic, int32(part), newTP.loadErr)
continue
}
// If the new partition has an older leader epoch, then we
// fetched from an out of date broker. We just keep the old
// information.
if newTP.leaderEpoch < oldTP.leaderEpoch {
// If we repeatedly rewind, then perhaps the cluster
// entered some bad state and lost forward progress.
// We will log & allow the rewind to allow the client
// to continue; other requests may encounter fenced
// epoch errors (and respectively recover).
//
// Five is a pretty low amount of retries, but since
// we iterate through known brokers, this basically
// means we keep stale metadata if five brokers all
// agree things rewound.
const maxEpochRewinds = 5
if oldTP.epochRewinds < maxEpochRewinds {
cl.cfg.logger.Log(LogLevelDebug, "metadata leader epoch went backwards, ignoring update",
"topic", topic,
"partition", part,
"old_leader_epoch", oldTP.leaderEpoch,
"new_leader_epoch", newTP.leaderEpoch,
"current_num_rewinds", oldTP.epochRewinds+1,
)
*newTP = *oldTP
newTP.epochRewinds++
retryWhy.add(topic, int32(part), errEpochRewind)
continue
}
cl.cfg.logger.Log(LogLevelInfo, "metadata leader epoch went backwards repeatedly, we are now keeping the metadata to allow forward progress",
"topic", topic,
"partition", part,
"old_leader_epoch", oldTP.leaderEpoch,
"new_leader_epoch", newTP.leaderEpoch,
)
}
if !isProduce {
var noID [16]byte
if newTP.cursor.topicID == noID && oldTP.cursor.topicID != noID {
cl.cfg.logger.Log(LogLevelWarn, "metadata update is missing the topic ID when we previously had one, ignoring update",
"topic", topic,
"partition", part,
)
*newTP = *oldTP
retryWhy.add(topic, int32(part), errMissingTopicID)
continue
}
}
// If the tp data is the same, we simply copy over the records
// and cursor pointers.
//
// If the tp data equals the old, then the sink / source is the
// same, because the sink/source is from the tp leader.
if newTP.topicPartitionData == oldTP.topicPartitionData {
cl.cfg.logger.Log(LogLevelDebug, "metadata refresh has identical topic partition data",
"topic", topic,
"partition", part,
"leader", newTP.leader,
"leader_epoch", newTP.leaderEpoch,
)
if isProduce {
newTP.records = oldTP.records
newTP.records.clearFailing() // always clear failing state for producing after meta update
} else {
newTP.cursor = oldTP.cursor // unlike records, there is no failing state for a cursor
}
} else {
cl.cfg.logger.Log(LogLevelDebug, "metadata refresh topic partition data changed",
"topic", topic,
"partition", part,
"new_leader", newTP.leader,
"new_leader_epoch", newTP.leaderEpoch,
"old_leader", oldTP.leader,
"old_leader_epoch", oldTP.leaderEpoch,
)
if isProduce {
oldTP.migrateProductionTo(newTP) // migration clears failing state
} else {
oldTP.migrateCursorTo(newTP, css)
}
}
}
// For any partitions **not currently in use**, we need to add them to
// the sink or source. If they are in use, they could be getting
// managed or moved by the sink or source itself, so we should not
// check the index field (which may be concurrently modified).
if len(lv.partitions) > len(r.partitions) {
return
}
newPartitions := r.partitions[len(lv.partitions):]
// Anything left with a negative recBufsIdx / cursorsIdx is a new topic
// partition and must be added to the sink / source.
for _, newTP := range newPartitions {
if isProduce && newTP.records.recBufsIdx == -1 {
newTP.records.sink.addRecBuf(newTP.records)
cl.cfg.logger.Log(LogLevelDebug, "metadata refresh new produce partition",
"topic", topic,
"partition", newTP.partition(),
"leader", newTP.leader,
"leader_epoch", newTP.leaderEpoch,
)
} else if !isProduce && newTP.cursor.cursorsIdx == -1 {
newTP.cursor.source.addCursor(newTP.cursor)
cl.cfg.logger.Log(LogLevelDebug, "metadata refresh new consume partition",
"topic", topic,
"partition", newTP.partition(),
"leader", newTP.leader,
"leader_epoch", newTP.leaderEpoch,
)
}
}
}
var (
errEpochRewind = errors.New("epoch rewind")
errMissingTopicID = errors.New("missing topic ID")
)
type multiUpdateWhy map[kerrOrString]map[string]map[int32]struct{}
type kerrOrString struct {
k *kerr.Error
s string
}
func (m *multiUpdateWhy) isOnly(err error) bool {
if m == nil {
return false
}
for e := range *m {
if !errors.Is(err, e.k) {
return false
}
}
return true
}
func (m *multiUpdateWhy) add(t string, p int32, err error) {
if err == nil {
return
}
if *m == nil {
*m = make(map[kerrOrString]map[string]map[int32]struct{})
}
var ks kerrOrString
if ke := (*kerr.Error)(nil); errors.As(err, &ke) {
ks = kerrOrString{k: ke}
} else {
ks = kerrOrString{s: err.Error()}
}
ts := (*m)[ks]
if ts == nil {
ts = make(map[string]map[int32]struct{})
(*m)[ks] = ts
}
ps := ts[t]
if ps == nil {
ps = make(map[int32]struct{})
ts[t] = ps
}
// -1 signals that the entire topic had an error.
if p != -1 {
ps[p] = struct{}{}
}
}
// err{topic[1 2 3] topic2[4 5 6]} err2{...}
func (m multiUpdateWhy) reason(reason string) string {
if len(m) == 0 {
return ""
}
ksSorted := make([]kerrOrString, 0, len(m))
for err := range m {
ksSorted = append(ksSorted, err)
}
sort.Slice(ksSorted, func(i, j int) bool { // order by non-nil kerr's code, otherwise the string
l, r := ksSorted[i], ksSorted[j]
return l.k != nil && (r.k == nil || l.k.Code < r.k.Code) || r.k == nil && l.s < r.s
})
var errorStrings []string
for _, ks := range ksSorted {
ts := m[ks]
tsSorted := make([]string, 0, len(ts))
for t := range ts {
tsSorted = append(tsSorted, t)
}
sort.Strings(tsSorted)
var topicStrings []string
for _, t := range tsSorted {
ps := ts[t]
if len(ps) == 0 {
topicStrings = append(topicStrings, t)
} else {
psSorted := make([]int32, 0, len(ps))
for p := range ps {
psSorted = append(psSorted, p)
}
slices.Sort(psSorted)
topicStrings = append(topicStrings, fmt.Sprintf("%s%v", t, psSorted))
}
}
if ks.k != nil {
errorStrings = append(errorStrings, fmt.Sprintf("%s{%s}", ks.k.Message, strings.Join(topicStrings, " ")))
} else {
errorStrings = append(errorStrings, fmt.Sprintf("%s{%s}", ks.s, strings.Join(topicStrings, " ")))
}
}
if reason == "" {
return strings.Join(errorStrings, " ")
}
return reason + ": " + strings.Join(errorStrings, " ")
}