mirror of
https://github.com/hanzoai/kafka.git
synced 2026-10-30 14:48:25 +00:00
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]>
259 lines
7.1 KiB
Go
259 lines
7.1 KiB
Go
package protocol
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/hanzoai/kafka/pubsub"
|
|
"github.com/hanzoai/kafka/types"
|
|
psembed "github.com/hanzoai/pubsub/embed"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/twmb/franz-go/pkg/kgo"
|
|
"github.com/twmb/franz-go/pkg/kmsg"
|
|
)
|
|
|
|
// stack is one embedded pubsub, a broker over it, and a client connection of
|
|
// its own for reading and writing the store behind the broker's back.
|
|
type stack struct {
|
|
ps *psembed.Server
|
|
b *Broker
|
|
addr string
|
|
nc *nats.Conn
|
|
js nats.JetStreamContext
|
|
}
|
|
|
|
func newStack(tb testing.TB) *stack {
|
|
tb.Helper()
|
|
ps, err := psembed.Open(psembed.Options{
|
|
Host: "127.0.0.1", Port: -1, ServerName: "kafka-protocol", StoreDir: tb.TempDir(),
|
|
})
|
|
if err != nil {
|
|
tb.Fatalf("pubsub open: %v", err)
|
|
}
|
|
tb.Cleanup(ps.Shutdown)
|
|
|
|
s := &stack{ps: ps}
|
|
s.b, s.addr = s.broker(tb)
|
|
s.nc, err = nats.Connect(ps.ClientURL())
|
|
if err != nil {
|
|
tb.Fatalf("store connect: %v", err)
|
|
}
|
|
tb.Cleanup(s.nc.Close)
|
|
if s.js, err = s.nc.JetStream(); err != nil {
|
|
tb.Fatalf("store jetstream: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// broker starts a broker over the stack's pubsub and returns it with the
|
|
// address it listens on.
|
|
func (s *stack) broker(tb testing.TB) (*Broker, string) {
|
|
tb.Helper()
|
|
b := NewBroker(&types.Configuration{
|
|
PubSubUrl: s.ps.ClientURL(),
|
|
BrokerHost: "127.0.0.1",
|
|
BrokerPort: 0,
|
|
NodeID: 1,
|
|
StreamReplicas: 1,
|
|
StorageType: "file",
|
|
})
|
|
errc := make(chan error, 1)
|
|
go func() { errc <- b.Serve() }()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for b.Addr() == nil {
|
|
select {
|
|
case err := <-errc:
|
|
tb.Fatalf("broker serve: %v", err)
|
|
default:
|
|
}
|
|
if time.Now().After(deadline) {
|
|
tb.Fatal("broker never bound")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
tb.Cleanup(b.Shutdown)
|
|
return b, b.Addr().String()
|
|
}
|
|
|
|
func (s *stack) topic(tb testing.TB, name string, partitions uint32) {
|
|
tb.Helper()
|
|
r := pubsub.Retention{MaxAge: types.DefaultRetentionMaxAge, MaxBytes: types.DefaultRetentionMaxBytes}
|
|
if err := s.b.PubSub.CreateTopicStreams(name, partitions, 1, nats.FileStorage, r); err != nil {
|
|
tb.Fatalf("create topic %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// produce writes n one-record batches to a partition through the broker at
|
|
// addr, each acknowledged before the next is sent.
|
|
func produce(tb testing.TB, addr, topic string, partition int32, n int) {
|
|
tb.Helper()
|
|
cl, err := kgo.NewClient(kgo.SeedBrokers(addr), kgo.RecordPartitioner(kgo.ManualPartitioner()))
|
|
if err != nil {
|
|
tb.Fatalf("producer: %v", err)
|
|
}
|
|
defer cl.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
for i := range n {
|
|
r := &kgo.Record{Topic: topic, Partition: partition, Value: fmt.Appendf(nil, "record-%04d", i)}
|
|
if err := cl.ProduceSync(ctx, r).FirstErr(); err != nil {
|
|
tb.Fatalf("produce %s/%d #%d: %v", topic, partition, i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// wire is one raw Kafka connection that sends a request and reads its
|
|
// response before sending the next, as a consumer's fetch connection does.
|
|
type wire struct {
|
|
c net.Conn
|
|
f *kmsg.RequestFormatter
|
|
cid int32
|
|
}
|
|
|
|
func dial(tb testing.TB, addr string) *wire {
|
|
tb.Helper()
|
|
c, err := net.Dial("tcp", addr)
|
|
if err != nil {
|
|
tb.Fatalf("dial %s: %v", addr, err)
|
|
}
|
|
tb.Cleanup(func() { c.Close() })
|
|
return &wire{c: c, f: kmsg.NewRequestFormatter(kmsg.FormatterClientID("protocol-test"))}
|
|
}
|
|
|
|
// do sends req at version v and decodes the response at the same version.
|
|
func (w *wire) do(v int16, req kmsg.Request) (kmsg.Response, error) {
|
|
req.SetVersion(v)
|
|
w.cid++
|
|
if _, err := w.c.Write(w.f.AppendRequest(nil, req, w.cid)); err != nil {
|
|
return nil, err
|
|
}
|
|
var size [4]byte
|
|
if _, err := io.ReadFull(w.c, size[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
body := make([]byte, binary.BigEndian.Uint32(size[:]))
|
|
if _, err := io.ReadFull(w.c, body); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) < 4 {
|
|
return nil, fmt.Errorf("response of %d bytes", len(body))
|
|
}
|
|
if cid := int32(binary.BigEndian.Uint32(body)); cid != w.cid {
|
|
return nil, fmt.Errorf("correlation id %d, want %d", cid, w.cid)
|
|
}
|
|
body = body[4:]
|
|
if req.IsFlexible() {
|
|
// A flexible response header ends in a tagged-field section.
|
|
n, k := binary.Uvarint(body)
|
|
if k <= 0 || n != 0 {
|
|
return nil, fmt.Errorf("response header tags: count %d (%d bytes)", n, k)
|
|
}
|
|
body = body[k:]
|
|
}
|
|
resp := req.ResponseKind()
|
|
resp.SetVersion(v)
|
|
return resp, resp.ReadFrom(body)
|
|
}
|
|
|
|
func (w *wire) fetch(v int16, req *kmsg.FetchRequest) (*kmsg.FetchResponse, error) {
|
|
resp, err := w.do(v, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.(*kmsg.FetchResponse), nil
|
|
}
|
|
|
|
// at is one partition position in a fetch request.
|
|
type at struct {
|
|
topic string
|
|
partition int32
|
|
offset int64
|
|
}
|
|
|
|
// fetchRequest builds a Fetch for the given positions the way consumers send
|
|
// one: no session, a 1 MiB partition limit, log start offset unknown (-1).
|
|
func fetchRequest(maxWaitMs, minBytes int32, positions ...at) *kmsg.FetchRequest {
|
|
req := kmsg.NewPtrFetchRequest()
|
|
req.MaxWaitMillis = maxWaitMs
|
|
req.MinBytes = minBytes
|
|
req.MaxBytes = 50 << 20
|
|
for _, pos := range positions {
|
|
if n := len(req.Topics); n == 0 || req.Topics[n-1].Topic != pos.topic {
|
|
t := kmsg.NewFetchRequestTopic()
|
|
t.Topic = pos.topic
|
|
req.Topics = append(req.Topics, t)
|
|
}
|
|
p := kmsg.NewFetchRequestTopicPartition()
|
|
p.Partition = pos.partition
|
|
p.FetchOffset = pos.offset
|
|
p.PartitionMaxBytes = 1 << 20
|
|
t := &req.Topics[len(req.Topics)-1]
|
|
t.Partitions = append(t.Partitions, p)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// cpuTime is the CPU this process has used, user plus system.
|
|
func cpuTime(tb testing.TB) time.Duration {
|
|
tb.Helper()
|
|
var ru syscall.Rusage
|
|
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
|
|
tb.Fatalf("getrusage: %v", err)
|
|
}
|
|
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
|
|
}
|
|
|
|
// apiLog records the JetStream API requests the store receives, by operation
|
|
// (STREAM.INFO, STREAM.MSG.GET, CONSUMER.CREATE, ...). It reads them off the
|
|
// API subjects rather than the account's API counter, which leaves out
|
|
// message gets.
|
|
type apiLog struct {
|
|
nc *nats.Conn
|
|
sub *nats.Subscription
|
|
}
|
|
|
|
func (s *stack) watchAPI(tb testing.TB) *apiLog {
|
|
tb.Helper()
|
|
sub, err := s.nc.SubscribeSync("$JS.API.>")
|
|
if err != nil {
|
|
tb.Fatalf("watch api: %v", err)
|
|
}
|
|
if err := sub.SetPendingLimits(-1, -1); err != nil {
|
|
tb.Fatalf("pending limits: %v", err)
|
|
}
|
|
return &apiLog{nc: s.nc, sub: sub}
|
|
}
|
|
|
|
// take returns the requests received since the last take. The store copies a
|
|
// request to this subscription before it answers it, so after a flush every
|
|
// request a finished call made has arrived.
|
|
func (a *apiLog) take(tb testing.TB) map[string]int {
|
|
tb.Helper()
|
|
if err := a.nc.Flush(); err != nil {
|
|
tb.Fatalf("flush: %v", err)
|
|
}
|
|
ops := map[string]int{}
|
|
for {
|
|
m, err := a.sub.NextMsg(10 * time.Millisecond)
|
|
if err != nil {
|
|
return ops
|
|
}
|
|
var op []string
|
|
for _, tok := range strings.Split(strings.TrimPrefix(m.Subject, "$JS.API."), ".") {
|
|
if strings.IndexFunc(tok, func(r rune) bool { return !unicode.IsUpper(r) && r != '_' }) >= 0 {
|
|
break
|
|
}
|
|
op = append(op, tok)
|
|
}
|
|
ops[strings.Join(op, ".")]++
|
|
}
|
|
}
|