Files
upstream-centrifugo/internal/consuming/kafka_test.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

2039 lines
60 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build integration
package consuming
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
"github.com/twmb/franz-go/pkg/kadm"
"github.com/twmb/franz-go/pkg/kgo"
)
const (
testKafkaBrokerURL = "localhost:29092"
)
// MockDispatcher implements the Dispatcher interface for testing.
type MockDispatcher struct {
onDispatchCommand func(ctx context.Context, method string, data []byte) error
onDispatchPublication func(ctx context.Context, channels []string, pub api.ConsumedPublication) error
}
func (m *MockDispatcher) DispatchCommand(ctx context.Context, method string, data []byte) error {
return m.onDispatchCommand(ctx, method, data)
}
func (m *MockDispatcher) DispatchPublication(ctx context.Context, channels []string, pub api.ConsumedPublication) error {
return m.onDispatchPublication(ctx, channels, pub)
}
func produceTestMessage(topic string, message []byte, headers []kgo.RecordHeader) error {
// Create a new client
client, err := kgo.NewClient(kgo.SeedBrokers(testKafkaBrokerURL))
if err != nil {
return fmt.Errorf("failed to create Kafka client: %w", err)
}
defer client.Close()
// Produce a message
err = client.ProduceSync(context.Background(), &kgo.Record{
Topic: topic,
Partition: 0,
Value: message,
Headers: headers,
}).FirstErr()
if err != nil {
return fmt.Errorf("failed to produce message: %w", err)
}
return nil
}
func produceManyRecords(records ...*kgo.Record) error {
client, err := kgo.NewClient(kgo.SeedBrokers(testKafkaBrokerURL))
if err != nil {
return fmt.Errorf("failed to create Kafka client: %w", err)
}
defer client.Close()
err = client.ProduceSync(context.Background(), records...).FirstErr()
if err != nil {
return fmt.Errorf("failed to produce message: %w", err)
}
return nil
}
func produceTestMessageToPartition(topic string, message []byte, partition int32) error {
client, err := kgo.NewClient(
kgo.SeedBrokers(testKafkaBrokerURL),
kgo.RecordPartitioner(kgo.ManualPartitioner()),
)
if err != nil {
return fmt.Errorf("failed to create Kafka client: %w", err)
}
defer client.Close()
res := client.ProduceSync(context.Background(), &kgo.Record{
Topic: topic, Partition: partition, Value: message})
if res.FirstErr() != nil {
return fmt.Errorf("failed to produce message: %w", err)
}
producedPartition := res[0].Record.Partition
if producedPartition != partition {
return fmt.Errorf("failed to produce message to partition %d, produced to %d", partition, producedPartition)
}
return nil
}
func createTestTopic(ctx context.Context, topicName string, numPartitions int32, replicationFactor int16) error {
cl, err := kgo.NewClient(kgo.SeedBrokers(testKafkaBrokerURL))
if err != nil {
return fmt.Errorf("failed to create Kafka client: %w", err)
}
defer cl.Close()
client := kadm.NewClient(cl)
defer client.Close()
// Create the topic
resp, err := client.CreateTopics(ctx, numPartitions, replicationFactor, nil, topicName)
if err != nil {
return fmt.Errorf("failed to create topic: %w", err)
}
for _, topic := range resp.Sorted() {
if topic.Err != nil {
if strings.Contains(topic.Err.Error(), "TOPIC_ALREADY_EXISTS") {
continue
}
return fmt.Errorf("failed to create topic '%s': %v", topic.Topic, topic.Err)
}
}
// CreateTopics returns once the controller has accepted the create, but
// partition leader election and metadata propagation are async. Poll
// until all expected partitions have a valid leader, otherwise a fresh
// producer/consumer client can hit UNKNOWN_TOPIC_OR_PARTITION.
deadline := time.Now().Add(15 * time.Second)
for {
details, err := client.ListTopics(ctx, topicName)
if err == nil {
td, ok := details[topicName]
if ok && td.Err == nil && int32(len(td.Partitions)) == numPartitions {
ready := true
for _, p := range td.Partitions {
if p.Err != nil || p.Leader < 0 {
ready = false
break
}
}
if ready {
return nil
}
}
}
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for topic '%s' partitions to become ready", topicName)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
}
func waitCh(t *testing.T, ch chan struct{}, timeout time.Duration, failureMessage string) {
t.Helper()
select {
case <-ch:
case <-time.After(timeout):
require.Fail(t, failureMessage)
}
}
func waitAnyCh(t *testing.T, channels []chan struct{}, timeout time.Duration, failureMessage string) {
t.Helper()
anyCh := make(chan struct{}, 1)
for _, ch := range channels {
go func(c chan struct{}) {
select {
case <-c:
select {
case anyCh <- struct{}{}:
default:
}
case <-time.After(timeout):
// let main handle timeout
}
}(ch)
}
select {
case <-anyCh:
// One of the channels received a signal
case <-time.After(timeout):
require.Fail(t, failureMessage)
}
}
func TestKafkaConsumer_GreenScenario(t *testing.T) {
t.Parallel()
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
testEvent := api.MethodWithRequestPayload{
Method: testMethod,
Payload: api.JSONRawOrString(testPayload),
}
testMessage, _ := json.Marshal(testEvent)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL}, // Adjust as needed
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
}
eventReceived := make(chan struct{})
consumerClosed := make(chan struct{})
consumer, err := NewKafkaConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
require.Equal(t, "", method)
require.Equal(t, testMessage, data)
close(eventReceived)
return nil
},
}, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
err = produceTestMessage(testKafkaTopic, testMessage, nil)
require.NoError(t, err)
waitCh(t, eventReceived, 30*time.Second, "timeout waiting for event")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
func TestKafkaConsumer_SeveralConsumers(t *testing.T) {
t.Parallel()
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL}, // Adjust as needed
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
}
eventReceived := make(chan struct{})
consumerClosed := make(chan struct{})
testEvent := api.MethodWithRequestPayload{
Method: testMethod,
Payload: api.JSONRawOrString(testPayload),
}
testMessage, _ := json.Marshal(testEvent)
for i := 0; i < 3; i++ {
consumer, err := NewKafkaConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
require.Equal(t, testMessage, data)
close(eventReceived)
return nil
},
}, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
consumerClosed <- struct{}{}
}()
}
err = produceTestMessage(testKafkaTopic, testMessage, nil)
require.NoError(t, err)
waitCh(t, eventReceived, 30*time.Second, "timeout waiting for event")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
func TestKafkaConsumer_RetryAfterDispatchError(t *testing.T) {
t.Parallel()
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
}
successCh := make(chan struct{})
consumerClosed := make(chan struct{})
retryCount := 0
numFailures := 3
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
if retryCount < numFailures {
retryCount++
return errors.New("dispatch error")
}
close(successCh)
return nil
},
}
consumer, err := NewKafkaConsumer(
config,
mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
testEvent := api.MethodWithRequestPayload{
Method: testMethod,
Payload: api.JSONRawOrString(testPayload),
}
testMessage, _ := json.Marshal(testEvent)
err = produceTestMessage(testKafkaTopic, testMessage, nil)
require.NoError(t, err)
waitCh(t, successCh, 30*time.Second, "timeout waiting for successful event process")
require.Equal(t, numFailures, retryCount)
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
// A context.Canceled coming from the dispatcher's own downstream call must not be mistaken
// for consumer shutdown: the partition consumer used to bail out on any context.Canceled,
// dropping the record and the rest of its batch. Since Kafka commits are offset based, the
// next batch then committed an offset past the dropped records, losing them for good.
func TestKafkaConsumer_UnrelatedContextCanceledIsRetried(t *testing.T) {
t.Parallel()
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
}
// A canceled context which has nothing to do with the consumer lifecycle.
unrelatedCtx, unrelatedCancel := context.WithCancel(context.Background())
unrelatedCancel()
makeMessage := func(key string) []byte {
message, err := json.Marshal(api.MethodWithRequestPayload{
Method: "method",
Payload: api.JSONRawOrString(`{"key":"` + key + `"}`),
})
require.NoError(t, err)
return message
}
firstMessage := makeMessage("first")
secondMessage := makeMessage("second")
const numFailures = 3
var mu sync.Mutex
var retryCount int
var dispatched [][]byte
allDispatched := make(chan struct{})
consumerClosed := make(chan struct{})
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(_ context.Context, _ string, data []byte) error {
mu.Lock()
defer mu.Unlock()
if bytes.Equal(data, firstMessage) && retryCount < numFailures {
retryCount++
return fmt.Errorf("dispatch error: %w", unrelatedCtx.Err())
}
dispatched = append(dispatched, data)
if len(dispatched) == 2 {
close(allDispatched)
}
return nil
},
}
consumer, err := NewKafkaConsumer(config, mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
err = produceManyRecords(
&kgo.Record{Topic: testKafkaTopic, Value: firstMessage},
&kgo.Record{Topic: testKafkaTopic, Value: secondMessage},
)
require.NoError(t, err)
waitCh(t, allDispatched, 30*time.Second, "timeout waiting for records to be processed")
mu.Lock()
require.Equal(t, numFailures, retryCount)
// Both records must be processed, in order – neither the retried one nor the rest of
// its batch may be skipped.
require.Equal(t, [][]byte{firstMessage, secondMessage}, dispatched)
mu.Unlock()
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
// In this test we simulate a scenario where a partition is blocked and the partition consumer
// is stuck on it. We want to make sure that the consumer is not blocked and can still process
// messages from other topics.
func TestKafkaConsumer_BlockedPartitionDoesNotBlockAnotherTopic(t *testing.T) {
t.Parallel()
testKafkaTopic1 := "consumer_test_1_" + uuid.New().String()
testKafkaTopic2 := "consumer_test_1_" + uuid.New().String()
testPayload1 := []byte(`{"key":"value1"}`)
testPayload2 := []byte(`{"key":"value2"}`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic1, 1, 1)
require.NoError(t, err)
err = createTestTopic(ctx, testKafkaTopic2, 1, 1)
require.NoError(t, err)
event1Received := make(chan struct{})
event2Received := make(chan struct{})
consumerClosed := make(chan struct{})
doneCh := make(chan struct{})
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic1, testKafkaTopic2},
ConsumerGroup: uuid.New().String(),
}
numCalls := 0
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
if numCalls == 0 {
numCalls++
close(event1Received)
// Block till the event2 received. This must not block the consumer and event2
// must still be processed successfully.
<-event2Received
return nil
}
close(event2Received)
return nil
},
}
consumer, err := NewKafkaConsumer(
config, mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err = produceTestMessage(testKafkaTopic1, testPayload1, nil)
require.NoError(t, err)
// Wait until the first message is received to make sure messages read by separate PollRecords calls.
<-event1Received
err = produceTestMessage(testKafkaTopic2, testPayload2, nil)
require.NoError(t, err)
}()
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
waitCh(t, event2Received, 30*time.Second, "timeout waiting for event 2")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
close(doneCh)
}
// In this test we simulate a scenario where a partition is blocked and the partition consumer
// is stuck on it. We want to make sure that the consumer is not blocked and can still process
// messages from other topic partitions.
func TestKafkaConsumer_BlockedPartitionDoesNotBlockAnotherPartition(t *testing.T) {
t.Parallel()
testKafkaTopic1 := "consumer_test_1_" + uuid.New().String()
testPayload1 := []byte(`{"key":"value1"}`)
testPayload2 := []byte(`{"key":"value2"}`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic1, 2, 1)
require.NoError(t, err)
event1Received := make(chan struct{})
event2Received := make(chan struct{})
consumerClosed := make(chan struct{})
doneCh := make(chan struct{})
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic1},
ConsumerGroup: uuid.New().String(),
}
numCalls := 0
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
if numCalls == 0 {
numCalls++
close(event1Received)
// Block till the event2 received. This must not block the consumer and event2
// must still be processed successfully.
<-event2Received
return nil
}
close(event2Received)
return nil
},
}
consumer, err := NewKafkaConsumer(
config, mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err = produceTestMessageToPartition(testKafkaTopic1, testPayload1, 0)
require.NoError(t, err)
// Wait until the first message is received to make sure messages read by separate PollRecords calls.
<-event1Received
err = produceTestMessageToPartition(testKafkaTopic1, testPayload2, 1)
require.NoError(t, err)
}()
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
waitCh(t, event2Received, 30*time.Second, "timeout waiting for event 2")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
close(doneCh)
}
func TestKafkaConsumer_PausePartitions(t *testing.T) {
t.Parallel()
testKafkaTopic := "consumer_test_" + uuid.New().String()
testPayload1 := []byte(`{"key":"value1"}`)
testPayload2 := []byte(`{"key":"value2"}`)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
event1Received := make(chan struct{})
event2Received := make(chan struct{})
consumerClosed := make(chan struct{})
doneCh := make(chan struct{})
fetchSubmittedCh := make(chan kgo.FetchTopicPartition)
beforePauseCh := make(chan topicPartition)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
PartitionQueueMaxSize: -1,
}
numCalls := 0
unblockCh := make(chan struct{})
testConfig := testOnlyConfig{
fetchTopicPartitionSubmittedCh: fetchSubmittedCh,
topicPartitionBeforePauseCh: beforePauseCh,
}
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
numCalls++
if numCalls == 1 {
close(event1Received)
<-unblockCh
return nil
}
close(event2Received)
return nil
},
}
consumer, err := NewKafkaConsumer(
config, mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
consumer.testOnlyConfig = testConfig
consumerCtx, consumerCancel := context.WithCancel(ctx)
defer consumerCancel()
go func() {
err = produceTestMessage(testKafkaTopic, testPayload1, nil)
require.NoError(t, err)
<-beforePauseCh // Wait for triggering the partition pause.
<-fetchSubmittedCh
<-event1Received
// Unblock the message processing.
close(unblockCh)
// At this point message 1 is being processed and the next produced message must
// cause a partition pause.
err = produceTestMessage(testKafkaTopic, testPayload2, nil)
require.NoError(t, err)
<-beforePauseCh // Wait for triggering the partition pause.
<-fetchSubmittedCh
}()
go func() {
err := consumer.Run(consumerCtx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
waitCh(t, event1Received, 50*time.Second, "timeout waiting for event 1")
waitCh(t, event2Received, 50*time.Second, "timeout waiting for event 2")
consumerCancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
close(doneCh)
}
func TestKafkaConsumer_WorksCorrectlyInLoadedTopic(t *testing.T) {
t.Skip()
t.Parallel()
testCases := []struct {
numPartitions int32
numMessages int
}{
//{numPartitions: 1, numMessages: 1000}
{numPartitions: 10, numMessages: 10000},
}
for _, tc := range testCases {
name := fmt.Sprintf("partitions=%d,messages=%d", tc.numPartitions, tc.numMessages)
t.Run(name, func(t *testing.T) {
testKafkaTopic := "consumer_test_" + uuid.New().String()
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, tc.numPartitions, 1)
require.NoError(t, err)
consumerClosed := make(chan struct{})
doneCh := make(chan struct{})
numMessages := tc.numMessages
messageCh := make(chan struct{}, numMessages)
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
// Emulate delay due to some work.
time.Sleep(20 * time.Millisecond)
messageCh <- struct{}{}
return nil
},
}
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
}
consumer, err := NewKafkaConsumer(
config, mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
var records []*kgo.Record
for i := 0; i < numMessages; i++ {
records = append(records, &kgo.Record{Topic: testKafkaTopic, Value: []byte(`{"hello": "` + strconv.Itoa(i) + `"}`)})
if (i+1)%100 == 0 {
err = produceManyRecords(records...)
if err != nil {
t.Fatal(err)
}
records = nil
t.Logf("produced %d messages", i+1)
}
}
t.Logf("all messages produced, 3, 2, 1, go!")
time.Sleep(time.Second)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
var numProcessed int64
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
t.Logf("processed %d messages", atomic.LoadInt64(&numProcessed))
}
}
}()
for i := 0; i < numMessages; i++ {
<-messageCh
atomic.AddInt64(&numProcessed, 1)
}
t.Logf("all messages processed")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
close(doneCh)
})
}
}
func TestKafkaConsumer_GreenScenario_PublicationDataMode(t *testing.T) {
t.Parallel()
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
testChannels := []string{"channel1", "channel2"}
testPayload := []byte(`{"key":"value"}`)
testIdempotencyKey := "test-idempotency-key"
const testDelta = true
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL}, // Adjust as needed
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
PublicationDataMode: configtypes.KafkaPublicationDataModeConfig{
Enabled: true,
ChannelsHeader: "centrifugo-channels",
IdempotencyKeyHeader: "centrifugo-idempotency-key",
DeltaHeader: "centrifugo-delta",
},
}
event1Received := make(chan struct{})
event2Received := make(chan struct{})
consumerClosed := make(chan struct{})
count := 0
mockDispatcher := &MockDispatcher{
onDispatchPublication: func(
ctx context.Context, channels []string, pub api.ConsumedPublication,
) error {
require.Nil(t, pub.Tags)
if count == 0 {
require.Len(t, channels, 1)
require.Equal(t, testChannels[0], channels[0])
require.Equal(t, testPayload, pub.Data)
require.Equal(t, testIdempotencyKey, pub.IdempotencyKey)
require.Equal(t, testDelta, pub.Delta)
close(event1Received)
} else {
require.Equal(t, testChannels, channels)
require.Equal(t, testPayload, pub.Data)
require.Equal(t, testIdempotencyKey, pub.IdempotencyKey)
require.Equal(t, testDelta, pub.Delta)
close(event2Received)
}
count++
return nil
},
}
consumer, err := NewKafkaConsumer(
config, mockDispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
err = produceTestMessage(testKafkaTopic, testPayload, []kgo.RecordHeader{
{
Key: config.PublicationDataMode.ChannelsHeader,
Value: []byte(testChannels[0]),
},
{
Key: config.PublicationDataMode.IdempotencyKeyHeader,
Value: []byte(testIdempotencyKey),
},
{
Key: config.PublicationDataMode.DeltaHeader,
Value: []byte(fmt.Sprintf("%v", testDelta)),
},
})
require.NoError(t, err)
err = produceTestMessage(testKafkaTopic, testPayload, []kgo.RecordHeader{
{
Key: config.PublicationDataMode.ChannelsHeader,
Value: []byte(strings.Join(testChannels, ",")),
},
{
Key: config.PublicationDataMode.IdempotencyKeyHeader,
Value: []byte(testIdempotencyKey),
},
{
Key: config.PublicationDataMode.DeltaHeader,
Value: []byte(fmt.Sprintf("%v", testDelta)),
},
})
require.NoError(t, err)
waitCh(t, event1Received, 30*time.Second, "timeout waiting for event1")
waitCh(t, event2Received, 30*time.Second, "timeout waiting for event2")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
func prePopulateTopic(b *testing.B, topic string, numPartitions int32, numMessages int) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
// Create topic
err := createTestTopic(ctx, topic, numPartitions, 1)
if err != nil {
b.Fatal(err)
}
b.Logf("created topic %s with %d partitions", topic, numPartitions)
// Pre-populate topic with messages in batches.
const batchSize = 10000
var records []*kgo.Record
startTime := time.Now()
for i := 0; i < numMessages; i++ {
records = append(records, &kgo.Record{Topic: topic, Value: []byte(`{"hello": "` + strconv.Itoa(i) + `"}`)})
if (i+1)%batchSize == 0 || i == numMessages-1 {
err = produceManyRecords(records...)
if err != nil {
b.Fatal(err)
}
records = nil
if (i+1)%10000 == 0 || i == numMessages-1 {
elapsed := time.Since(startTime)
rate := float64(i+1) / elapsed.Seconds()
b.Logf("produced %d/%d messages (%.0f msg/sec)", i+1, numMessages, rate)
}
}
}
totalTime := time.Since(startTime)
overallRate := float64(numMessages) / totalTime.Seconds()
b.Logf("finished producing %d messages in %v (%.0f msg/sec)", numMessages, totalTime, overallRate)
}
func runConsumptionIteration(b *testing.B, topic string, numMessages int, iteration int) time.Duration {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
messageCh := make(chan struct{}, numMessages)
consumerClosed := make(chan struct{})
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
messageCh <- struct{}{}
return nil
},
}
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{topic},
ConsumerGroup: uuid.New().String(),
MaxPollRecords: 100,
FetchMaxBytes: 10 * 1024 * 1024, // 10 MB.
FetchMaxWait: configtypes.Duration(200 * time.Millisecond),
PartitionQueueMaxSize: 1000,
}
consumer, err := NewKafkaConsumer(
config, mockDispatcher, testCommon(prometheus.NewRegistry()))
if err != nil {
b.Fatal(err)
}
b.Logf("iteration %d: starting consumer with group %s", iteration, config.ConsumerGroup)
startTime := time.Now()
go func() {
err := consumer.Run(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
b.Logf("consumer error: %v", err)
}
close(consumerClosed)
}()
// Wait for all messages to be consumed
processedCount := 0
for j := 0; j < numMessages; j++ {
select {
case <-messageCh:
processedCount++
// Log progress every 100 messages.
if processedCount%100 == 0 {
elapsed := time.Since(startTime)
rate := float64(processedCount) / elapsed.Seconds()
b.Logf("iteration %d: processed %d/%d messages (%.0f msg/sec)", iteration, processedCount, numMessages, rate)
}
case <-time.After(60 * time.Second):
b.Fatal("timeout waiting for messages")
}
}
consumptionTime := time.Since(startTime)
rate := float64(numMessages) / consumptionTime.Seconds()
b.Logf("iteration %d: consumed %d messages in %v (%.0f msg/sec)", iteration, numMessages, consumptionTime, rate)
cancel()
select {
case <-consumerClosed:
case <-time.After(30 * time.Second):
b.Fatal("timeout waiting for consumer to close")
}
return consumptionTime
}
func makeFTP(topic string, partition int32, numRecords int) kgo.FetchTopicPartition {
records := make([]*kgo.Record, 0, numRecords)
for i := 0; i < numRecords; i++ {
records = append(records, &kgo.Record{Value: []byte("record")})
}
return kgo.FetchTopicPartition{Topic: topic, FetchPartition: kgo.FetchPartition{Partition: partition, Records: records}}
}
func TestUnboundedQueue_BasicPushPopNumRecords(t *testing.T) {
q := newUnboundedQueue()
require.False(t, q.IsClosed(), "new queue should not be closed")
require.Equal(t, 0, q.NumRecords(), "new queue should have length 0")
// Push items.
for i := 0; i < 5; i++ {
ok := q.Push(makeFTP("topic", int32(i), 1))
require.True(t, ok, "push should succeed")
require.Equal(t, i+1, q.NumRecords(), "num records should increase after push")
}
// Pop items in FIFO order.
for i := 0; i < 5; i++ {
item, ok := q.Pop()
require.True(t, ok, "pop should succeed when items are available")
require.Equal(t, "topic", item.Topic)
require.Equal(t, int32(i), item.Partition)
require.Equal(t, 5-(i+1), q.NumRecords(), "num records should decrease after pop")
}
// Now empty.
item, ok := q.Pop()
require.False(t, ok, "pop on empty queue should return ok=false")
require.Equal(t, kgo.FetchTopicPartition{}, item, "item should be zero value when pop fails")
require.Equal(t, 0, q.NumRecords())
}
func TestUnboundedQueue_MaintainsProperNumRecords(t *testing.T) {
q := newUnboundedQueue()
ok := q.Push(makeFTP("topic", int32(0), 5))
require.True(t, ok, "push should succeed")
ok = q.Push(makeFTP("topic", int32(0), 10))
require.True(t, ok, "push should succeed")
require.Equal(t, 15, q.NumRecords(), "length should be sum of records in all items")
item, ok := q.Pop()
require.True(t, ok, "pop on empty queue should return ok=false")
require.Equal(t, 5, len(item.Records))
require.Equal(t, 10, q.NumRecords())
item, ok = q.Pop()
require.True(t, ok, "pop on empty queue should return ok=false")
require.Equal(t, 10, len(item.Records))
require.Equal(t, 0, q.NumRecords())
}
func TestUnboundedQueue_NotEmptySignal(t *testing.T) {
q := newUnboundedQueue()
// 1) Initially no signal.
select {
case <-q.NotEmpty():
require.Fail(t, "NotEmpty channel should not signal before any push")
default:
}
// 2) First push should signal.
require.True(t, q.Push(makeFTP("t", 0, 1)))
// 3) Immediately push two more times (buffer is size 1, so they shouldn't signal).
require.True(t, q.Push(makeFTP("t", 1, 1)))
require.True(t, q.Push(makeFTP("t", 2, 1)))
// 4) Now there should be exactly one signal in the channel.
// Drain it.
select {
case <-q.NotEmpty():
// good: we got the one and only signal
case <-time.After(50 * time.Millisecond):
require.Fail(t, "expected one signal after three pushes")
}
// 5) After draining, channel must now be empty again.
select {
case <-q.NotEmpty():
require.Fail(t, "no extra signals until another push after drain")
default:
}
// 6) One more push now signals again.
require.True(t, q.Push(makeFTP("t", 3, 1)))
select {
case <-q.NotEmpty():
// expected
case <-time.After(50 * time.Millisecond):
require.Fail(t, "push after drain should signal NotEmpty again")
}
}
func TestUnboundedQueue_CloseAndIsClosed(t *testing.T) {
q := newUnboundedQueue()
require.False(t, q.IsClosed())
// Close first time.
q.Close()
require.True(t, q.IsClosed(), "queue should report closed after Close()")
// Close second time is a no-op (no panic).
require.NotPanics(t, q.Close, "calling Close() twice should not panic")
require.True(t, q.IsClosed(), "still closed after second Close()")
// NotEmpty channel should be closed.
_, ok := <-q.NotEmpty()
require.False(t, ok, "NotEmpty channel should be closed after Close()")
// Push fails once closed.
ok = q.Push(makeFTP("x", 1, 1))
require.False(t, ok, "push after close should return false")
// Pop always fails when closed, even if items were enqueued before
// Let's test with a fresh queue.
q2 := newUnboundedQueue()
q2.Push(makeFTP("y", 2, 1))
q2.Close()
item, popped := q2.Pop()
require.False(t, popped, "pop after close should return false")
require.Equal(t, kgo.FetchTopicPartition{}, item, "item should be zero on pop after close")
}
func TestUnboundedQueue_ConcurrentSafety(t *testing.T) {
q := newUnboundedQueue()
const n = 100
// Start n producers.
for i := 0; i < n; i++ {
go func(i int) {
ok := q.Push(makeFTP("c", int32(i), 1))
require.True(t, ok)
}(i)
}
// Start n consumers.
results := make(chan kgo.FetchTopicPartition, n)
for i := 0; i < n; i++ {
go func() {
for {
item, ok := q.Pop()
if !ok {
if q.IsClosed() {
// If queue is closed and no items left, exit.
return
}
continue
}
results <- item
}
}()
}
// Collect.
seen := make(map[int32]bool)
for i := 0; i < n; i++ {
ftp := <-results
require.False(t, seen[ftp.Partition], "each partition should be unique: %v", ftp.Partition)
seen[ftp.Partition] = true
}
require.Equal(t, 0, q.NumRecords(), "queue should be empty after all pops")
}
// TestKafkaConsumer_RollingRestart compares message delivery delays during a 1-by-1
// rolling restart of 3 consumers across 3 instance ID modes:
// - dynamic_instance_id: new unique instance ID each time (old default behavior)
// - no_instance_id: no static membership (new default)
// - stable_instance_id: stable instance IDs like centrifugo-0, centrifugo-1, centrifugo-2
func TestKafkaConsumer_RollingRestart(t *testing.T) {
t.Skip()
const (
numPartitions = int32(6)
numConsumers = 3
)
type consumerHandle struct {
cancel context.CancelFunc
doneCh chan struct{}
}
type mode struct {
name string
instanceID func(consumerIndex int) string
leaveGroupOnShutdown bool // simulate old behavior: manual LeaveGroup for static members
}
modes := []mode{
{
name: "dynamic_instance_id",
instanceID: func(i int) string { return "centrifugo-" + uuid.New().String() },
leaveGroupOnShutdown: true, // old behavior: manual LeaveGroup for dynamic static members
},
{
name: "no_instance_id",
instanceID: func(i int) string { return "" },
},
{
name: "stable_instance_id",
instanceID: func(i int) string { return fmt.Sprintf("centrifugo-stable-%d", i) },
},
}
type restartResult struct {
consumerIndex int
delay time.Duration
}
results := make(map[string][]restartResult)
for _, m := range modes {
m := m
t.Run(m.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
topic := "rollout_test_" + uuid.New().String()
group := "rollout_group_" + uuid.New().String()
err := createTestTopic(ctx, topic, numPartitions, 1)
require.NoError(t, err)
// Start continuous producer that sends to all partitions frequently.
producerCtx, producerCancel := context.WithCancel(ctx)
defer producerCancel()
producer, err := kgo.NewClient(
kgo.SeedBrokers(testKafkaBrokerURL),
kgo.RecordPartitioner(kgo.ManualPartitioner()),
)
require.NoError(t, err)
go func() {
defer producer.Close()
for {
select {
case <-producerCtx.Done():
return
default:
}
for p := int32(0); p < numPartitions; p++ {
producer.ProduceSync(producerCtx, &kgo.Record{
Topic: topic,
Partition: p,
Value: []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli())),
})
}
time.Sleep(50 * time.Millisecond)
}
}()
startConsumer := func(index int) (*consumerHandle, <-chan struct{}) {
instanceID := m.instanceID(index)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{topic},
ConsumerGroup: group,
InstanceID: instanceID,
}
firstMessageCh := make(chan struct{})
firstMessageReceived := atomic.Bool{}
dispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
if firstMessageReceived.CompareAndSwap(false, true) {
close(firstMessageCh)
}
return nil
},
}
consumer, consumerErr := NewKafkaConsumer(config, dispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, consumerErr)
consumer.testOnlyConfig.leaveGroupOnShutdown = m.leaveGroupOnShutdown
consumerCtx, consumerCancel := context.WithCancel(ctx)
doneCh := make(chan struct{})
go func() {
_ = consumer.Run(consumerCtx)
close(doneCh)
}()
return &consumerHandle{
cancel: consumerCancel,
doneCh: doneCh,
}, firstMessageCh
}
// Start initial consumers and wait for each to be active.
handles := make([]*consumerHandle, numConsumers)
for i := 0; i < numConsumers; i++ {
h, firstMsg := startConsumer(i)
handles[i] = h
select {
case <-firstMsg:
t.Logf("initial consumer %d is active", i)
case <-time.After(60 * time.Second):
t.Fatalf("timeout waiting for initial consumer %d to start processing", i)
}
}
// Wait for steady state.
time.Sleep(5 * time.Second)
t.Log("steady state reached, starting rolling restart")
// Rolling restart: stop and replace consumers one by one.
var modeResults []restartResult
for i := 0; i < numConsumers; i++ {
restartStart := time.Now()
// Stop old consumer.
handles[i].cancel()
select {
case <-handles[i].doneCh:
case <-time.After(30 * time.Second):
t.Fatalf("timeout waiting for consumer %d to stop", i)
}
stopDuration := time.Since(restartStart)
// Start replacement.
var firstMsg <-chan struct{}
handles[i], firstMsg = startConsumer(i)
// Wait for replacement to process its first message.
select {
case <-firstMsg:
case <-time.After(60 * time.Second):
t.Fatalf("timeout waiting for replacement consumer %d to start processing", i)
}
delay := time.Since(restartStart)
modeResults = append(modeResults, restartResult{consumerIndex: i, delay: delay})
t.Logf("consumer %d: stop=%v, total restart delay=%v", i, stopDuration, delay)
// Let things stabilize before next restart.
time.Sleep(2 * time.Second)
}
results[m.name] = modeResults
// Cleanup.
for i := 0; i < numConsumers; i++ {
handles[i].cancel()
<-handles[i].doneCh
}
producerCancel()
})
}
// Print comparison summary.
t.Log("\n========== ROLLING RESTART COMPARISON ==========")
for _, m := range modes {
modeResults := results[m.name]
var total time.Duration
for _, r := range modeResults {
total += r.delay
}
t.Logf("\n--- %s ---", m.name)
for _, r := range modeResults {
t.Logf(" consumer %d restart: %v", r.consumerIndex, r.delay)
}
t.Logf(" TOTAL rollout delay: %v", total)
}
t.Log("================================================")
}
// TestKafkaConsumer_CommitsOffsetsOnShutdown verifies that offsets marked just before
// shutdown are actually committed to Kafka. After the consumer processes all messages
// and is shut down, we use kadm to check that committed offsets match the number of
// produced messages.
func TestKafkaConsumer_CommitsOffsetsOnShutdown(t *testing.T) {
t.Parallel()
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
consumerGroup := uuid.New().String()
const numMessages = 5
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := createTestTopic(ctx, testKafkaTopic, 1, 1)
require.NoError(t, err)
// Produce messages before starting the consumer.
for i := 0; i < numMessages; i++ {
err = produceTestMessage(testKafkaTopic, []byte(fmt.Sprintf(`{"msg":%d}`, i)), nil)
require.NoError(t, err)
}
allProcessed := make(chan struct{})
var processedCount atomic.Int32
consumerClosed := make(chan struct{})
consumerCtx, consumerCancel := context.WithCancel(ctx)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: consumerGroup,
}
consumer, err := NewKafkaConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
if int(processedCount.Add(1)) == numMessages {
close(allProcessed)
}
return nil
},
}, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
_ = consumer.Run(consumerCtx)
close(consumerClosed)
}()
waitCh(t, allProcessed, 30*time.Second, "timeout waiting for all messages to be processed")
// Shut down consumer — this should commit marked offsets.
consumerCancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer to close")
// Check committed offsets via kadm.
cl, err := kgo.NewClient(kgo.SeedBrokers(testKafkaBrokerURL))
require.NoError(t, err)
defer cl.Close()
admClient := kadm.NewClient(cl)
defer admClient.Close()
offsets, err := admClient.FetchOffsets(ctx, consumerGroup)
require.NoError(t, err)
offset, ok := offsets.Lookup(testKafkaTopic, 0)
require.True(t, ok, "committed offset not found for topic %s partition 0", testKafkaTopic)
require.Equal(t, int64(numMessages), offset.At,
"committed offset should equal number of produced messages")
}
// syncBuffer is a concurrency-safe buffer for capturing zerolog output in tests.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.String()
}
// testCommonWithLogBuf builds a consumerCommon whose zerolog logger writes JSON
// records into buf so tests can assert on log messages.
func testCommonWithLogBuf(buf *syncBuffer) *consumerCommon {
return &consumerCommon{
name: "test",
nodeID: uuid.New().String(),
log: zerolog.New(buf).With().Str("consumer", "test").Logger(),
}
}
// producerState tracks every successfully produced message value.
type producerState struct {
mu sync.Mutex
values []string
}
func (p *producerState) add(v string) {
p.mu.Lock()
defer p.mu.Unlock()
p.values = append(p.values, v)
}
func (p *producerState) snapshot() []string {
p.mu.Lock()
defer p.mu.Unlock()
return slices.Clone(p.values)
}
// consumedTracker tracks unique consumed message values and total dispatch calls.
type consumedTracker struct {
mu sync.Mutex
values map[string]struct{}
totalCalls int
}
func newConsumedTracker() *consumedTracker {
return &consumedTracker{values: make(map[string]struct{})}
}
func (c *consumedTracker) add(v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.values[v] = struct{}{}
c.totalCalls++
}
func (c *consumedTracker) snapshotSet() map[string]struct{} {
c.mu.Lock()
defer c.mu.Unlock()
out := make(map[string]struct{}, len(c.values))
for v := range c.values {
out[v] = struct{}{}
}
return out
}
func (c *consumedTracker) uniqueCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.values)
}
func (c *consumedTracker) totalCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.totalCalls
}
// runMultiPartitionProducer produces messages "msg_1", "msg_2", ... round-robin
// across all partitions of the topic. PollRecords blocks until records are
// available, so a steady traffic stream is required to make the consumer poll
// loop iterate and pick up an injected fatal error from the test-only channel.
func runMultiPartitionProducer(
t *testing.T,
ctx context.Context,
topic string,
numPartitions int32,
interval time.Duration,
) (*producerState, chan struct{}) {
t.Helper()
client, err := kgo.NewClient(
kgo.SeedBrokers(testKafkaBrokerURL),
kgo.RecordPartitioner(kgo.ManualPartitioner()),
)
require.NoError(t, err)
state := &producerState{}
done := make(chan struct{})
go func() {
defer close(done)
defer client.Close()
ticker := time.NewTicker(interval)
defer ticker.Stop()
var seq int
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
seq++
value := fmt.Sprintf("msg_%d", seq)
partition := int32(seq-1) % numPartitions
err := client.ProduceSync(ctx, &kgo.Record{
Topic: topic,
Partition: partition,
Value: []byte(value),
}).FirstErr()
if err != nil {
if ctx.Err() != nil {
return
}
t.Logf("produce error: %v", err)
return
}
state.add(value)
}
}
}()
return state, done
}
// assertNoMessageLoss asserts that every successfully produced message was consumed.
// Allows redeliveries (totalCount >= produced count).
func assertNoMessageLoss(t *testing.T, prod *producerState, cons *consumedTracker) {
t.Helper()
producedList := prod.snapshot()
producedSet := make(map[string]struct{}, len(producedList))
for _, v := range producedList {
producedSet[v] = struct{}{}
}
consumedSet := cons.snapshotSet()
var missing []string
for v := range producedSet {
if _, ok := consumedSet[v]; !ok {
missing = append(missing, v)
}
}
require.Empty(t, missing, "produced messages were not consumed (message loss): %v", missing)
require.Equal(t, len(producedSet), len(consumedSet),
"unique consumed set size must equal unique produced set size")
require.Equal(t, producedSet, consumedSet,
"consumed set must equal produced set")
require.GreaterOrEqual(t, cons.totalCount(), len(producedList),
"total consume calls must be at least produced count")
}
// runConsumerAsync starts a KafkaConsumer in a goroutine and returns a channel
// that receives the Run error when it returns.
func runConsumerAsync(t *testing.T, ctx context.Context, c *KafkaConsumer) chan error {
t.Helper()
done := make(chan error, 1)
go func() {
done <- c.Run(ctx)
}()
return done
}
// TestKafkaConsumer_NoMessageLossOnReInit verifies that when a fatal poll error
// triggers client re-initialization, every produced message is eventually
// consumed and no messages are lost. Uses a multi-partition topic with
// round-robin distribution so multiple partition consumers are exercised.
func TestKafkaConsumer_NoMessageLossOnReInit(t *testing.T) {
t.Parallel()
const numPartitions int32 = 4
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
consumerGroup := uuid.New().String()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
require.NoError(t, createTestTopic(ctx, testKafkaTopic, numPartitions, 1))
producerCtx, producerCancel := context.WithCancel(ctx)
defer producerCancel()
prod, producerDone := runMultiPartitionProducer(t, producerCtx, testKafkaTopic, numPartitions, 50*time.Millisecond)
consumed := newConsumedTracker()
thresholdReached := make(chan struct{})
var thresholdOnce sync.Once
const triggerAfter = 10
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(_ context.Context, _ string, data []byte) error {
consumed.add(string(data))
if consumed.uniqueCount() >= triggerAfter {
thresholdOnce.Do(func() { close(thresholdReached) })
}
return nil
},
}
var logBuf syncBuffer
fatalErrCh := make(chan error, 1)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: consumerGroup,
}
consumer, err := NewKafkaConsumer(config, mockDispatcher, testCommonWithLogBuf(&logBuf))
require.NoError(t, err)
consumer.testOnlyConfig.injectFatalPollErrorCh = fatalErrCh
consumerCtx, consumerCancel := context.WithCancel(ctx)
consumerErr := runConsumerAsync(t, consumerCtx, consumer)
waitCh(t, thresholdReached, 30*time.Second, "timeout waiting for threshold messages")
t.Logf("threshold reached (unique=%d), injecting fatal error", consumed.uniqueCount())
consumedAtInjection := consumed.uniqueCount()
fatalErrCh <- errors.New("injected fatal poll error")
require.Eventually(t, func() bool {
return strings.Contains(logBuf.String(), "Kafka consumer client re-initialized")
}, 30*time.Second, 50*time.Millisecond, "expected client re-initialization log entry")
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= consumedAtInjection+3
}, 30*time.Second, 50*time.Millisecond, "expected processing to resume after re-init")
producerCancel()
<-producerDone
produced := prod.snapshot()
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= len(produced)
}, 30*time.Second, 100*time.Millisecond,
"expected all produced messages to be consumed")
assertNoMessageLoss(t, prod, consumed)
t.Logf("final: produced=%d, unique=%d, total=%d, redeliveries=%d",
len(produced), consumed.uniqueCount(), consumed.totalCount(), consumed.totalCount()-len(produced))
logs := logBuf.String()
require.Contains(t, logs, "fatal error polling Kafka, need client re-init")
require.Contains(t, logs, "start re-initializing Kafka consumer client")
require.Contains(t, logs, "Kafka consumer client re-initialized")
consumerCancel()
select {
case err := <-consumerErr:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(15 * time.Second):
t.Fatal("timeout waiting for consumer to close")
}
}
// TestKafkaConsumer_MultipleReInitsNoMessageLoss verifies that the consumer
// survives multiple consecutive fatal errors, processes all produced messages,
// and that committed offsets cover the full topic at the end.
func TestKafkaConsumer_MultipleReInitsNoMessageLoss(t *testing.T) {
t.Parallel()
const (
numPartitions int32 = 4
numReInits = 2
)
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
consumerGroup := uuid.New().String()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
require.NoError(t, createTestTopic(ctx, testKafkaTopic, numPartitions, 1))
producerCtx, producerCancel := context.WithCancel(ctx)
defer producerCancel()
prod, producerDone := runMultiPartitionProducer(t, producerCtx, testKafkaTopic, numPartitions, 50*time.Millisecond)
consumed := newConsumedTracker()
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(_ context.Context, _ string, data []byte) error {
consumed.add(string(data))
return nil
},
}
var logBuf syncBuffer
fatalErrCh := make(chan error, 1)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: consumerGroup,
}
consumer, err := NewKafkaConsumer(config, mockDispatcher, testCommonWithLogBuf(&logBuf))
require.NoError(t, err)
consumer.testOnlyConfig.injectFatalPollErrorCh = fatalErrCh
consumerCtx, consumerCancel := context.WithCancel(ctx)
consumerErr := runConsumerAsync(t, consumerCtx, consumer)
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= 5
}, 30*time.Second, 50*time.Millisecond, "expected initial messages to be consumed")
for i := range numReInits {
fatalErrCh <- fmt.Errorf("injected fatal error #%d", i+1)
t.Logf("injected fatal error %d/%d (unique=%d)", i+1, numReInits, consumed.uniqueCount())
expectedReInits := i + 1
require.Eventually(t, func() bool {
return strings.Count(logBuf.String(), "Kafka consumer client re-initialized") >= expectedReInits
}, 30*time.Second, 50*time.Millisecond,
fmt.Sprintf("expected at least %d re-init log entries", expectedReInits))
baseline := consumed.uniqueCount()
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= baseline+3
}, 30*time.Second, 50*time.Millisecond,
"expected processing to resume after re-init before next injection")
}
producerCancel()
<-producerDone
produced := prod.snapshot()
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= len(produced)
}, 30*time.Second, 100*time.Millisecond,
"expected all produced messages to be consumed")
assertNoMessageLoss(t, prod, consumed)
logs := logBuf.String()
require.GreaterOrEqual(t, strings.Count(logs, "fatal error polling Kafka, need client re-init"), numReInits)
require.GreaterOrEqual(t, strings.Count(logs, "start re-initializing Kafka consumer client"), numReInits)
require.GreaterOrEqual(t, strings.Count(logs, "Kafka consumer client re-initialized"), numReInits)
t.Logf("after %d re-inits: produced=%d, unique=%d, total=%d, redeliveries=%d",
numReInits, len(produced), consumed.uniqueCount(), consumed.totalCount(),
consumed.totalCount()-len(produced))
consumerCancel()
select {
case err := <-consumerErr:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(15 * time.Second):
t.Fatal("timeout waiting for consumer to close")
}
}
// TestKafkaConsumer_ReInitPreservesCommittedOffsets verifies that after a fatal
// error and client re-initialization, committed offsets eventually cover every
// partition's full content.
func TestKafkaConsumer_ReInitPreservesCommittedOffsets(t *testing.T) {
t.Parallel()
const numPartitions int32 = 4
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
consumerGroup := uuid.New().String()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
require.NoError(t, createTestTopic(ctx, testKafkaTopic, numPartitions, 1))
producerCtx, producerCancel := context.WithCancel(ctx)
defer producerCancel()
prod, producerDone := runMultiPartitionProducer(t, producerCtx, testKafkaTopic, numPartitions, 50*time.Millisecond)
consumed := newConsumedTracker()
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(_ context.Context, _ string, data []byte) error {
consumed.add(string(data))
return nil
},
}
var logBuf syncBuffer
fatalErrCh := make(chan error, 1)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: consumerGroup,
}
consumer, err := NewKafkaConsumer(config, mockDispatcher, testCommonWithLogBuf(&logBuf))
require.NoError(t, err)
consumer.testOnlyConfig.injectFatalPollErrorCh = fatalErrCh
consumerCtx, consumerCancel := context.WithCancel(ctx)
consumerErr := runConsumerAsync(t, consumerCtx, consumer)
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= 5
}, 30*time.Second, 50*time.Millisecond, "timeout waiting for initial messages")
fatalErrCh <- errors.New("injected fatal error")
require.Eventually(t, func() bool {
return strings.Contains(logBuf.String(), "Kafka consumer client re-initialized")
}, 30*time.Second, 50*time.Millisecond, "expected re-init completion")
baseline := consumed.uniqueCount()
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= baseline+5
}, 30*time.Second, 50*time.Millisecond, "expected processing to resume after re-init")
producerCancel()
<-producerDone
produced := prod.snapshot()
require.Eventually(t, func() bool {
return consumed.uniqueCount() >= len(produced)
}, 30*time.Second, 100*time.Millisecond,
"expected all produced messages to be consumed")
assertNoMessageLoss(t, prod, consumed)
logs := logBuf.String()
require.Contains(t, logs, "fatal error polling Kafka, need client re-init")
require.Contains(t, logs, "start re-initializing Kafka consumer client")
require.Contains(t, logs, "Kafka consumer client re-initialized")
// Wait until committed offsets cover every partition's full content.
require.Eventually(t, func() bool {
cl, err := kgo.NewClient(kgo.SeedBrokers(testKafkaBrokerURL))
if err != nil {
return false
}
defer cl.Close()
admClient := kadm.NewClient(cl)
defer admClient.Close()
queryCtx, queryCancel := context.WithTimeout(ctx, 5*time.Second)
defer queryCancel()
endOffsets, err := admClient.ListEndOffsets(queryCtx, testKafkaTopic)
if err != nil {
return false
}
committed, err := admClient.FetchOffsets(queryCtx, consumerGroup)
if err != nil {
return false
}
allMatch := true
endOffsets.Each(func(o kadm.ListedOffset) {
c, ok := committed.Lookup(testKafkaTopic, o.Partition)
if !ok || c.At != o.Offset {
allMatch = false
}
})
return allMatch
}, 30*time.Second, 200*time.Millisecond,
"expected committed offsets to match end offsets on all partitions")
consumerCancel()
select {
case err := <-consumerErr:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(15 * time.Second):
t.Fatal("timeout waiting for consumer to close")
}
}
// TestKafkaConsumer_GracefulShutdownDuringReInit verifies that cancelling the
// context after a fatal error has triggered the re-init flow causes Run to
// return promptly with the context error. The test waits for the "start
// re-initializing" log entry before cancelling — re-init can occasionally
// complete before the cancellation lands, in which case we are cancelling
// the next poll loop iteration rather than mid-re-init proper. Either way,
// Run must return within the 10s bound.
func TestKafkaConsumer_GracefulShutdownDuringReInit(t *testing.T) {
t.Parallel()
const numPartitions int32 = 4
testKafkaTopic := "centrifugo_consumer_test_" + uuid.New().String()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
require.NoError(t, createTestTopic(ctx, testKafkaTopic, numPartitions, 1))
producerCtx, producerCancel := context.WithCancel(ctx)
_, producerDone := runMultiPartitionProducer(t, producerCtx, testKafkaTopic, numPartitions, 100*time.Millisecond)
defer func() {
producerCancel()
<-producerDone
}()
eventReceived := make(chan struct{})
var eventOnce sync.Once
mockDispatcher := &MockDispatcher{
onDispatchCommand: func(_ context.Context, _ string, _ []byte) error {
eventOnce.Do(func() { close(eventReceived) })
return nil
},
}
var logBuf syncBuffer
fatalErrCh := make(chan error, 1)
config := KafkaConfig{
Brokers: []string{testKafkaBrokerURL},
Topics: []string{testKafkaTopic},
ConsumerGroup: uuid.New().String(),
}
consumer, err := NewKafkaConsumer(config, mockDispatcher, testCommonWithLogBuf(&logBuf))
require.NoError(t, err)
consumer.testOnlyConfig.injectFatalPollErrorCh = fatalErrCh
consumerCtx, consumerCancel := context.WithCancel(ctx)
consumerErr := runConsumerAsync(t, consumerCtx, consumer)
waitCh(t, eventReceived, 30*time.Second, "timeout waiting for first event")
fatalErrCh <- errors.New("injected fatal error for shutdown test")
// Wait until the re-init flow has actually started, so we know we're
// cancelling the context mid-re-init.
require.Eventually(t, func() bool {
s := logBuf.String()
return strings.Contains(s, "fatal error polling Kafka, need client re-init") &&
strings.Contains(s, "start re-initializing Kafka consumer client")
}, 15*time.Second, 25*time.Millisecond, "expected re-init flow to start before context cancellation")
shutdownStart := time.Now()
consumerCancel()
select {
case runErr := <-consumerErr:
shutdownDuration := time.Since(shutdownStart)
require.ErrorIs(t, runErr, context.Canceled)
require.Less(t, shutdownDuration, 10*time.Second,
"consumer should shut down promptly when context is cancelled during re-init")
t.Logf("consumer shut down in %v after context cancellation", shutdownDuration)
case <-time.After(15 * time.Second):
t.Fatal("consumer did not shut down in time after context cancellation during re-init")
}
logs := logBuf.String()
require.Contains(t, logs, "fatal error polling Kafka, need client re-init")
require.Contains(t, logs, "start re-initializing Kafka consumer client")
}
func BenchmarkKafkaConsumer_ConsumePreLoadedTopic(b *testing.B) {
const (
numPartitions = 10
numMessages = 100000
)
testKafkaTopic := "consumer_bench_" + uuid.New().String()
prePopulateTopic(b, testKafkaTopic, numPartitions, numMessages)
b.ResetTimer()
var totalTime time.Duration
for i := 0; i < b.N; i++ {
iterationTime := runConsumptionIteration(b, testKafkaTopic, numMessages, i+1)
totalTime += iterationTime
}
if b.N > 0 {
avgTime := totalTime / time.Duration(b.N)
avgRate := float64(numMessages) / avgTime.Seconds()
b.Logf("benchmark complete: %d iterations, avg time %v, avg rate %.0f msg/sec", b.N, avgTime, avgRate)
}
}
func TestEffectiveDialTimeout(t *testing.T) {
t.Parallel()
t.Run("zero uses default", func(t *testing.T) {
t.Parallel()
require.Equal(t, defaultKafkaDialTimeout, effectiveDialTimeout(0))
})
t.Run("negative uses default", func(t *testing.T) {
t.Parallel()
require.Equal(t, defaultKafkaDialTimeout, effectiveDialTimeout(configtypes.Duration(-1*time.Second)))
})
t.Run("positive value is preserved", func(t *testing.T) {
t.Parallel()
require.Equal(t, 2*time.Second, effectiveDialTimeout(configtypes.Duration(2*time.Second)))
})
}
func TestPingTimeout(t *testing.T) {
t.Parallel()
t.Run("accounts for 2 attempts per broker", func(t *testing.T) {
t.Parallel()
require.Equal(t, 54*time.Second, pingTimeout(3*time.Second, 9))
})
t.Run("single broker is 2x dial timeout", func(t *testing.T) {
t.Parallel()
require.Equal(t, 6*time.Second, pingTimeout(3*time.Second, 1))
})
t.Run("zero brokers returns zero", func(t *testing.T) {
t.Parallel()
require.Equal(t, time.Duration(0), pingTimeout(3*time.Second, 0))
})
}