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

213 lines
7.4 KiB
Go

package protocol
import (
log "github.com/hanzoai/kafka/logging"
"github.com/hanzoai/kafka/serde"
"github.com/hanzoai/kafka/types"
"github.com/hanzoai/kafka/utils"
)
// ProduceRequest represents the details of a ProduceRequest.
type ProduceRequest struct {
TransactionalID string `kafka:"CompactNullableString"`
Acks uint16
TimeoutMs uint32
TopicData []ProduceRequestTopicData
}
// ProduceRequestTopicData represents the topic data in a ProduceRequest.
type ProduceRequestTopicData struct {
Name string `kafka:"CompactString"`
PartitionData []ProduceRequestPartitionData
}
// ProduceRequestPartitionData represents the partition data in a ProduceRequest.
type ProduceRequestPartitionData struct {
Index uint32
Records []byte
}
// ProduceResponse represents the response to a produce request.
type ProduceResponse struct {
ProduceTopicResponses []ProduceTopicResponse
ThrottleTimeMs uint32
}
// ProduceTopicResponse represents the response for a topic in a produce request.
type ProduceTopicResponse struct {
Name string `kafka:"CompactString"`
ProducePartitionResponses []ProducePartitionResponse
}
// ProducePartitionResponse represents the response for a partition in a produce request.
type ProducePartitionResponse struct {
Index uint32
ErrorCode uint16
BaseOffset uint64
LogAppendTimeMs uint64
LogStartOffset uint64
RecordErrors []RecordError
ErrorMessage string `kafka:"CompactString"`
}
// RecordError represents an error in a specific batch of records.
type RecordError struct {
BatchIndex uint32
BatchIndexErrorMessage string // compact_nullable
}
func decodeProduceRequest(d serde.Decoder, produceRequest *ProduceRequest, apiVersion uint16) {
if apiVersion >= 9 {
// Flexible version: compact strings/arrays + tagged fields
produceRequest.TransactionalID = d.CompactString()
produceRequest.Acks = d.UInt16()
produceRequest.TimeoutMs = d.UInt32()
lenTopicData := int(d.CompactArrayLen())
for i := 0; i < lenTopicData; i++ {
topic := ProduceRequestTopicData{Name: d.CompactString()}
lenPartitionData := int(d.CompactArrayLen())
for j := 0; j < lenPartitionData; j++ {
topic.PartitionData = append(topic.PartitionData, ProduceRequestPartitionData{
Index: d.UInt32(), Records: d.CompactBytes(),
})
d.EndStruct()
}
produceRequest.TopicData = append(produceRequest.TopicData, topic)
d.EndStruct()
}
} else {
// Non-flexible: NULLABLE_STRING for transactional_id, STRING for names,
// INT32 for array counts and byte lengths (-1 = null for nullable fields)
produceRequest.TransactionalID = d.NullableString()
produceRequest.Acks = d.UInt16()
produceRequest.TimeoutMs = d.UInt32()
lenTopicData := int(int32(d.UInt32()))
for i := 0; i < lenTopicData; i++ {
topic := ProduceRequestTopicData{Name: d.NullableString()}
lenPartitionData := int(int32(d.UInt32()))
for j := 0; j < lenPartitionData; j++ {
index := d.UInt32()
// Records: int32 length prefix + bytes (-1 = null)
recordsLen := int32(d.UInt32())
var records []byte
if recordsLen > 0 {
records = d.GetNBytes(int(recordsLen))
}
topic.PartitionData = append(topic.PartitionData, ProduceRequestPartitionData{
Index: index, Records: records,
})
}
produceRequest.TopicData = append(produceRequest.TopicData, topic)
}
}
}
func (b *Broker) getProduceResponse(req types.Request) []byte {
decoder := serde.NewDecoder(req.Body)
produceRequest := &ProduceRequest{}
decodeProduceRequest(decoder, produceRequest, req.RequestAPIVersion)
log.Debug("ProduceRequest %+v", produceRequest)
response := ProduceResponse{}
for _, td := range produceRequest.TopicData {
produceTopicResponse := ProduceTopicResponse{Name: td.Name}
for _, pd := range td.PartitionData {
partitionResponse := ProducePartitionResponse{
Index: pd.Index,
}
_, err := b.PubSub.GetStreamInfo(td.Name, pd.Index)
if err != nil {
partitionResponse.ErrorCode = uint16(ErrUnknownTopicOrPartition.Code)
partitionResponse.ErrorMessage = ErrUnknownTopicOrPartition.Message
} else {
mu := b.partitionLock(td.Name, pd.Index)
mu.Lock()
bd, offsetErr := b.partitionBounds(td.Name, pd.Index)
nextOffset := bd.next
if offsetErr != nil {
mu.Unlock()
log.Error("Error computing next offset: %v", offsetErr)
partitionResponse.ErrorCode = uint16(ErrUnknownServerError.Code)
partitionResponse.ErrorMessage = offsetErr.Error()
} else if _, ok := stampBaseOffsets(pd.Records, nextOffset); !ok {
// Not a valid RecordBatch chain. Storing it would poison the
// partition for every consumer, so it is refused at the door.
mu.Unlock()
partitionResponse.ErrorCode = uint16(ErrInvalidRecord.Code)
partitionResponse.ErrorMessage = ErrInvalidRecord.Message
} else {
_, err := b.PubSub.Publish(td.Name, pd.Index, pd.Records)
mu.Unlock()
if err != nil {
log.Error("Error publishing to PubSub: %v", err)
partitionResponse.ErrorCode = uint16(ErrUnknownServerError.Code)
partitionResponse.ErrorMessage = err.Error()
} else {
partitionResponse.BaseOffset = uint64(nextOffset)
partitionResponse.LogAppendTimeMs = utils.NowAsUnixMilli()
// Fetches parked on this partition, on any broker, wake
// to read the append. A lost announcement costs them
// only their remaining MaxWaitMs.
if err := b.PubSub.AnnounceTail(td.Name, pd.Index); err != nil {
log.Warn("announce tail %s/%d: %v", td.Name, pd.Index, err)
}
}
}
}
produceTopicResponse.ProducePartitionResponses = append(produceTopicResponse.ProducePartitionResponses, partitionResponse)
}
response.ProduceTopicResponses = append(response.ProduceTopicResponses, produceTopicResponse)
}
return encodeProduceResponse(req, response)
}
// encodeProduceResponse manually encodes the Produce response based on API version.
// v0-v8: non-flexible format (int32 arrays, int16 strings, no tagged fields).
//
// v7 fields: index, error_code, base_offset, log_append_time_ms, log_start_offset
// v8 adds: record_errors, error_message
//
// v9+: flexible format (compact arrays/strings, tagged field terminators).
func encodeProduceResponse(req types.Request, response ProduceResponse) []byte {
e := serde.NewEncoder()
e.PutInt32(req.CorrelationID)
if req.RequestAPIVersion >= 9 {
// Flexible response header: tagged fields after correlation_id
e.EndStruct()
e.Encode(response)
} else {
// Non-flexible response: manual encoding without tagged fields
// topics array (int32 count)
e.PutArrayLen(len(response.ProduceTopicResponses))
for _, topic := range response.ProduceTopicResponses {
e.PutString(topic.Name)
// partitions array (int32 count)
e.PutArrayLen(len(topic.ProducePartitionResponses))
for _, p := range topic.ProducePartitionResponses {
e.PutInt32(p.Index)
e.PutInt16(p.ErrorCode)
e.PutInt64(p.BaseOffset)
if req.RequestAPIVersion >= 2 {
e.PutInt64(p.LogAppendTimeMs)
}
if req.RequestAPIVersion >= 5 {
e.PutInt64(p.LogStartOffset)
}
if req.RequestAPIVersion >= 8 {
// record_errors: empty array
e.PutArrayLen(len(p.RecordErrors))
// error_message: nullable string
e.PutNullableString(p.ErrorMessage)
}
}
}
if req.RequestAPIVersion >= 1 {
e.PutInt32(response.ThrottleTimeMs)
}
}
e.PutLen()
return e.Bytes()
}