Files
zeekayandHanzo Dev 0c384b11ba Hold caught-up fetches for MaxWaitMs and cache partition bounds
A fetch with nothing to read read both partition edges and then slept
a fixed 300 ms whatever the client asked for. Sixty caught-up
consumers kept the broker and the store busy with no record moving.

Bounds are now kept per partition stream with the state they were read
from (stream created time, first and last sequence, message count). A
read whose StreamInfo reports the same state answers from them, so an
unchanged partition costs one StreamInfo and reads no message. Bounds
read through a failed or overtaken read serve that call and are not
kept.

Fetch answers the way Kafka's delayed fetch does: at once on MinBytes
or any partition error, otherwise when a requested partition gets a
new tail or MaxWaitMs runs out, capped at 30 s. A fetch serves one
record set per partition, so one that leaves records behind answers at
once too: waiting could not bring it closer to MinBytes. Each produce announces
its partition's new tail on a core subject after the store acks it, so
a fetch parked on one broker wakes for a produce served by any broker
on the bus. Shutdown answers parked fetches.

BenchmarkIdleFetch on main (9a065af) and on this commit: 60 caught-up
consumers over 20 topics, Fetch v12, MaxWaitMs 500, three runs of 1200
fetches each:

            cores       JetStream API/s   consumers/s   fetch/s
  before    0.48-0.53   887-916           177-183       177-183
  after     0.056-0.067 119               0             119

Before, each fetch made five API requests (stream info, stream names,
consumer create, consumer delete, message get); after, one stream info.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-03 12:25:33 -07:00

344 lines
11 KiB
Go

package protocol
import (
"time"
log "github.com/hanzoai/kafka/logging"
"github.com/hanzoai/kafka/pubsub"
"github.com/hanzoai/kafka/serde"
"github.com/hanzoai/kafka/types"
)
// FetchRequest represents the details of a FetchRequest (Version: 12).
type FetchRequest struct {
ReplicaID uint32
MaxWaitMs uint32
MinBytes uint32
MaxBytes uint32
IsolationLevel uint8
SessionID uint32
SessionEpoch uint32
Topics []FetchRequestTopic
ForgottenTopicsData []FetchRequestForgottenTopic
RackID string `kafka:"CompactString"`
}
// FetchRequestTopic represents the topic-level data in a FetchRequest.
type FetchRequestTopic struct {
Name string `kafka:"CompactString"`
Partitions []FetchRequestPartitionData
}
// FetchRequestPartitionData represents the partition-level data in a FetchRequest.
type FetchRequestPartitionData struct {
PartitionIndex uint32
CurrentLeaderEpoch uint32
FetchOffset uint64
LastFetchedEpoch uint32
LogStartOffset uint64
PartitionMaxBytes uint32
}
// FetchRequestForgottenTopic represents the forgotten topic data in a FetchRequest.
type FetchRequestForgottenTopic struct {
Topic string `kafka:"CompactString"`
Partitions []uint32
}
// FetchResponse represents the response to a fetch request.
type FetchResponse struct {
ThrottleTimeMs uint32
ErrorCode uint16
SessionID uint32
Responses []FetchTopicResponse
}
// FetchTopicResponse represents the response for a topic in a fetch request.
type FetchTopicResponse struct {
TopicName string `kafka:"CompactString"`
Partitions []FetchPartitionResponse
}
// FetchPartitionResponse represents the response for a partition in a fetch request.
type FetchPartitionResponse struct {
PartitionIndex uint32
ErrorCode uint16
HighWatermark uint64
LastStableOffset uint64
LogStartOffset uint64
AbortedTransactions []AbortedTransaction
PreferredReadReplica uint32
Records []byte
}
// AbortedTransaction represents an aborted transaction in the fetch response.
type AbortedTransaction struct {
ProducerID uint64
FirstOffset uint64
}
func decodeFetchRequest(d serde.Decoder, fetchRequest *FetchRequest, apiVersion uint16) {
fetchRequest.ReplicaID = d.UInt32()
fetchRequest.MaxWaitMs = d.UInt32()
fetchRequest.MinBytes = d.UInt32()
if apiVersion >= 3 {
fetchRequest.MaxBytes = d.UInt32()
}
if apiVersion >= 4 {
fetchRequest.IsolationLevel = d.UInt8()
}
if apiVersion >= 7 {
fetchRequest.SessionID = d.UInt32()
fetchRequest.SessionEpoch = d.UInt32()
}
if apiVersion >= 12 {
// Flexible version: compact arrays/strings + tagged fields
lenTopic := int(d.CompactArrayLen())
for i := 0; i < lenTopic; i++ {
topic := FetchRequestTopic{Name: d.CompactString()}
lenPartitions := int(d.CompactArrayLen())
for j := 0; j < lenPartitions; j++ {
p := FetchRequestPartitionData{
PartitionIndex: d.UInt32(),
CurrentLeaderEpoch: d.UInt32(),
FetchOffset: d.UInt64(),
LastFetchedEpoch: d.UInt32(),
LogStartOffset: d.UInt64(),
PartitionMaxBytes: d.UInt32(),
}
topic.Partitions = append(topic.Partitions, p)
d.EndStruct()
}
fetchRequest.Topics = append(fetchRequest.Topics, topic)
d.EndStruct()
}
// forgotten topics (compact array)
lenForgotten := int(d.CompactArrayLen())
for i := 0; i < lenForgotten; i++ {
ft := FetchRequestForgottenTopic{Topic: d.CompactString()}
lenParts := int(d.CompactArrayLen())
for j := 0; j < lenParts; j++ {
ft.Partitions = append(ft.Partitions, d.UInt32())
}
fetchRequest.ForgottenTopicsData = append(fetchRequest.ForgottenTopicsData, ft)
d.EndStruct()
}
fetchRequest.RackID = d.CompactString()
} else {
// Non-flexible: int32 array counts, int16 strings
lenTopic := int(int32(d.UInt32()))
for i := 0; i < lenTopic; i++ {
topic := FetchRequestTopic{Name: d.NullableString()}
lenPartitions := int(int32(d.UInt32()))
for j := 0; j < lenPartitions; j++ {
p := FetchRequestPartitionData{
PartitionIndex: d.UInt32(),
}
if apiVersion >= 9 {
p.CurrentLeaderEpoch = d.UInt32()
}
p.FetchOffset = d.UInt64()
if apiVersion >= 5 {
p.LogStartOffset = d.UInt64()
}
p.PartitionMaxBytes = d.UInt32()
topic.Partitions = append(topic.Partitions, p)
}
fetchRequest.Topics = append(fetchRequest.Topics, topic)
}
if apiVersion >= 7 {
// forgotten topics (int32 array)
lenForgotten := int(int32(d.UInt32()))
for i := 0; i < lenForgotten; i++ {
ft := FetchRequestForgottenTopic{Topic: d.NullableString()}
lenParts := int(int32(d.UInt32()))
for j := 0; j < lenParts; j++ {
ft.Partitions = append(ft.Partitions, d.UInt32())
}
fetchRequest.ForgottenTopicsData = append(fetchRequest.ForgottenTopicsData, ft)
}
}
if apiVersion >= 11 {
fetchRequest.RackID = d.NullableString()
}
}
}
// maxFetchWait caps how long a fetch is held for data. A client waits for the
// response for at least the MaxWaitMs it sent, so answering earlier is always
// valid; the cap bounds what a misconfigured or hostile client can pin.
const maxFetchWait = 30 * time.Second
// fetchWait is how long a fetch with nothing to return is held. MaxWaitMs is
// an INT32 on the wire: zero or negative means answer at once.
func fetchWait(maxWaitMs uint32) time.Duration {
ms := int32(maxWaitMs)
if ms <= 0 {
return 0
}
return min(time.Duration(ms)*time.Millisecond, maxFetchWait)
}
// getFetchResponse answers a Fetch the way Kafka's delayed fetch does: at once
// when the partitions hold MinBytes of records, a partition holds more than
// this response can carry, or any partition answers with an error; otherwise
// once a requested partition gets a new tail that satisfies it or MaxWaitMs
// runs out, whichever comes first. Reading an
// unchanged partition costs one StreamInfo, on arrival and on each wake; a
// parked fetch costs nothing while it waits.
func (b *Broker) getFetchResponse(req types.Request) []byte {
start := time.Now()
decoder := serde.NewDecoder(req.Body)
fetchRequest := &FetchRequest{}
decodeFetchRequest(decoder, fetchRequest, req.RequestAPIVersion)
log.Debug("fetchRequest %+v", fetchRequest)
minBytes := int(int32(fetchRequest.MinBytes))
deadline := start.Add(fetchWait(fetchRequest.MaxWaitMs))
var streams []string
for _, tp := range fetchRequest.Topics {
for _, p := range tp.Partitions {
streams = append(streams, pubsub.StreamName(tp.Name, p.PartitionIndex))
}
}
// Park before the first read: a tail announced while the partitions are
// being read is then held in wake rather than lost.
wake := make(chan struct{}, 1)
if time.Until(deadline) > 0 && len(streams) > 0 {
defer b.park(streams, wake)()
}
for {
response, size, now := b.fetchOnce(fetchRequest)
wait := time.Until(deadline)
if now || size >= minBytes || wait <= 0 || len(streams) == 0 {
return encodeFetchResponse(req, response)
}
timer := time.NewTimer(wait)
select {
case <-wake:
timer.Stop()
continue
case <-timer.C:
case <-b.ShutDownSignal:
timer.Stop()
}
return encodeFetchResponse(req, response)
}
}
// fetchOnce reads every requested partition as the store holds it now. It
// returns the response, the record bytes in it, and whether to answer
// without waiting for MinBytes: a partition answered with an error, or a
// partition holds records past the one record set a fetch serves it, so
// waiting cannot make this response any fuller.
func (b *Broker) fetchOnce(fetchRequest *FetchRequest) (response FetchResponse, size int, now bool) {
for _, tp := range fetchRequest.Topics {
fetchTopicResponse := FetchTopicResponse{TopicName: tp.Name}
for _, p := range tp.Partitions {
pr := FetchPartitionResponse{
PartitionIndex: p.PartitionIndex,
PreferredReadReplica: uint32(MinusOne), // -1 = no preferred replica
}
bd, err := b.partitionBounds(tp.Name, p.PartitionIndex)
if err != nil {
pr.ErrorCode = uint16(ErrUnknownTopicOrPartition.Code)
} else {
pr.HighWatermark = uint64(bd.next)
pr.LastStableOffset = uint64(bd.next)
pr.LogStartOffset = uint64(bd.logStart)
offset := int64(p.FetchOffset)
switch {
case offset < bd.logStart || offset > bd.next:
// Out of range is an answer, not a stall: the client resets
// per its auto.offset.reset policy. Serving empty here
// instead (the old behavior) left consumers polling a dead
// position forever whenever their committed offset stopped
// existing (purge, retention, an operator reset).
pr.ErrorCode = uint16(ErrOffsetOutOfRange.Code)
case offset == bd.next:
// caught up: empty records, no error
default:
if msg := b.findRecordSet(tp.Name, p.PartitionIndex, offset, bd); msg != nil {
pr.Records = msg.Data
size += len(msg.Data)
if _, last, ok := batchSpan(msg.Data); ok && last+1 < bd.next {
now = true
}
}
}
}
now = now || pr.ErrorCode != 0
fetchTopicResponse.Partitions = append(fetchTopicResponse.Partitions, pr)
}
response.Responses = append(response.Responses, fetchTopicResponse)
}
return response, size, now
}
// encodeFetchResponse manually encodes the Fetch response based on API version.
// v0-v11: non-flexible format (int32 arrays, int16 strings, no tagged fields).
// v12+: flexible format (compact arrays/strings, tagged field terminators).
func encodeFetchResponse(req types.Request, response FetchResponse) []byte {
e := serde.NewEncoder()
e.PutInt32(req.CorrelationID)
if req.RequestAPIVersion >= 12 {
// Flexible response header
e.EndStruct()
e.Encode(response)
} else {
// Non-flexible response
if req.RequestAPIVersion >= 1 {
e.PutInt32(response.ThrottleTimeMs)
}
if req.RequestAPIVersion >= 7 {
e.PutInt16(response.ErrorCode)
e.PutInt32(response.SessionID)
}
// responses array
e.PutArrayLen(len(response.Responses))
for _, topic := range response.Responses {
e.PutString(topic.TopicName)
e.PutArrayLen(len(topic.Partitions))
for _, p := range topic.Partitions {
e.PutInt32(p.PartitionIndex)
e.PutInt16(p.ErrorCode)
e.PutInt64(p.HighWatermark)
if req.RequestAPIVersion >= 4 {
e.PutInt64(p.LastStableOffset)
}
if req.RequestAPIVersion >= 5 {
e.PutInt64(p.LogStartOffset)
}
if req.RequestAPIVersion >= 4 {
// aborted transactions (int32 array, -1 = null)
if len(p.AbortedTransactions) == 0 {
e.PutInt32(uint32(MinusOne)) // null array = -1
} else {
e.PutArrayLen(len(p.AbortedTransactions))
for _, at := range p.AbortedTransactions {
e.PutInt64(at.ProducerID)
e.PutInt64(at.FirstOffset)
}
}
}
if req.RequestAPIVersion >= 11 {
e.PutInt32(p.PreferredReadReplica)
}
// records: int32 length prefix + bytes (-1 = null)
if len(p.Records) == 0 {
e.PutInt32(uint32(MinusOne)) // null
} else {
e.PutInt32(uint32(len(p.Records)))
e.PutBytes(p.Records)
}
}
}
}
e.PutLen()
return e.Bytes()
}