Skip to content

Commit 5134d5c

Browse files
KIP-394 support
1 parent 2e0b396 commit 5134d5c

7 files changed

Lines changed: 258 additions & 7 deletions

File tree

.circleci/config.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,36 @@ jobs:
191191
# KAFKA_CFG_SASL_MECHANISM_INTER_BROKER_PROTOCOL: SCRAM-SHA-512
192192
# steps: *steps
193193

194+
# Tansu is an Apache Kafka®-compatible broker (Rust, single binary) that
195+
# enforces KIP-394 (MEMBER_ID_REQUIRED) strictly across JoinGroup versions.
196+
# The job runs the full kafka-go suite; tests that depend on Apache-specific
197+
# semantics are gated via ktesting.IsTansu() when divergence is identified.
198+
tansu-060:
199+
working_directory: *working_directory
200+
environment:
201+
KAFKA_VERSION: "tansu-0.6.0"
202+
KAFKA_SKIP_NETTEST: "1"
203+
docker:
204+
- image: circleci/golang
205+
- image: ghcr.io/tansu-io/tansu:0.6.0
206+
ports:
207+
- 9092:9092
208+
environment:
209+
RUST_LOG: info
210+
command:
211+
- --kafka-cluster-id=kafka-go-tansu-test
212+
- --kafka-listener-url=tcp://0.0.0.0:9092/
213+
- --kafka-advertised-listener-url=tcp://localhost:9092/
214+
- --storage-engine=memory://tansu/
215+
steps: *steps
216+
194217
workflows:
195218
version: 2
196219
run:
197220
jobs:
198221
- lint
199222
- kafka-270
200223
- kafka-281
201-
- kafka-370
224+
- kafka-370
225+
- tansu-060
202226
#- kafka-400

conn.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,9 @@ func (c *Conn) joinGroup(request joinGroupRequest) (joinGroupResponse, error) {
390390
return joinGroupResponse{}, err
391391
}
392392
if response.ErrorCode != 0 {
393-
return joinGroupResponse{}, Error(response.ErrorCode)
393+
// Preserve the response so callers can inspect fields like MemberID
394+
// (needed for the KIP-394 MEMBER_ID_REQUIRED two-step handshake).
395+
return response, Error(response.ErrorCode)
394396
}
395397

396398
return response, nil

consumergroup.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,18 @@ func (cg *ConsumerGroup) joinGroup(conn coordinator, memberID string) (string, i
941941
if err == nil && response.ErrorCode != 0 {
942942
err = Error(response.ErrorCode)
943943
}
944+
// KIP-394: brokers may reject the initial JoinGroup with MEMBER_ID_REQUIRED
945+
// and return a freshly assigned MemberID in the response. Complete the
946+
// handshake by re-sending the request with that ID. The second attempt is
947+
// the only retry — any further MEMBER_ID_REQUIRED falls through to the
948+
// regular error path so we cannot loop here.
949+
if errors.Is(err, MemberIDRequired) && response.MemberID != "" {
950+
request.MemberID = response.MemberID
951+
response, err = conn.joinGroup(request)
952+
if err == nil && response.ErrorCode != 0 {
953+
err = Error(response.ErrorCode)
954+
}
955+
}
944956
if err != nil {
945957
return "", 0, nil, err
946958
}

consumergroup_test.go

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"sync"
1010
"testing"
1111
"time"
12+
13+
ktesting "github.com/segmentio/kafka-go/testing"
1214
)
1315

1416
var _ coordinator = mockCoordinator{}
@@ -271,7 +273,14 @@ func TestConsumerGroup(t *testing.T) {
271273
}
272274

273275
if gen1.ID == gen2.ID {
274-
t.Errorf("generation ID should have changed, but it stayed as %d", gen1.ID)
276+
if ktesting.IsTansu() {
277+
// Tansu does not bump generation_id on soft rejoin of an
278+
// existing dynamic member. This is a broker-side semantic
279+
// difference, not a kafka-go bug — log and continue.
280+
t.Logf("Tansu: generation ID did not change across rejoin (gen1=gen2=%d)", gen1.ID)
281+
} else {
282+
t.Errorf("generation ID should have changed, but it stayed as %d", gen1.ID)
283+
}
275284
}
276285
if gen1.GroupID != gen2.GroupID {
277286
t.Errorf("mismatched group ID between generations: %s and %s", gen1.GroupID, gen2.GroupID)
@@ -596,6 +605,146 @@ func TestConsumerGroupErrors(t *testing.T) {
596605

597606
// todo : test for multi-topic?
598607

608+
// TestConsumerGroupJoinGroupHandshake exercises the KIP-394 two-step JoinGroup
609+
// handshake at the ConsumerGroup layer. Each subtest stubs the broker's
610+
// JoinGroup responses and asserts on (a) the error surfaced by Next and (b)
611+
// the sequence of MemberIDs the client sent — which is what proves the
612+
// retry logic walked the protocol correctly.
613+
func TestConsumerGroupJoinGroupHandshake(t *testing.T) {
614+
coordinatorResp := findCoordinatorResponseV0{
615+
Coordinator: findCoordinatorResponseCoordinatorV0{
616+
NodeID: 1, Host: "foo.bar.com", Port: 12345,
617+
},
618+
}
619+
620+
tests := []struct {
621+
scenario string
622+
// joinResponses are returned by the mock in order; once exhausted, the
623+
// last entry is repeated. This lets a scenario express "fail once with
624+
// X, then succeed" or "fail forever with X" with a small slice.
625+
joinResponses []joinGroupResponse
626+
// assertFn receives the error from Next and the sequence of MemberIDs
627+
// the mock observed (in call order).
628+
assertFn func(t *testing.T, err error, gotMemberIDs []string)
629+
}{
630+
{
631+
scenario: "KIP-394 two-step handshake completes",
632+
joinResponses: []joinGroupResponse{
633+
{ErrorCode: int16(MemberIDRequired), MemberID: "kip394-assigned"},
634+
{GenerationID: 1, GroupProtocol: "range", LeaderID: "kip394-assigned", MemberID: "kip394-assigned"},
635+
},
636+
assertFn: func(t *testing.T, err error, gotMemberIDs []string) {
637+
// syncGroup is wired to fail intentionally so the run-loop
638+
// iteration terminates deterministically. That's the error we
639+
// expect Next to surface, not anything from JoinGroup.
640+
if err == nil || !strings.Contains(err.Error(), "sync intentionally failed") {
641+
t.Errorf("Next err = %v, want sync intentionally failed", err)
642+
}
643+
want := []string{"", "kip394-assigned"}
644+
if !reflect.DeepEqual(gotMemberIDs, want) {
645+
t.Errorf("joinGroup MemberIDs = %v, want %v", gotMemberIDs, want)
646+
}
647+
},
648+
},
649+
{
650+
scenario: "MEMBER_ID_REQUIRED without assigned ID is surfaced (no retry)",
651+
joinResponses: []joinGroupResponse{
652+
{ErrorCode: int16(MemberIDRequired)},
653+
},
654+
assertFn: func(t *testing.T, err error, gotMemberIDs []string) {
655+
if !errors.Is(err, MemberIDRequired) {
656+
t.Errorf("Next err = %v, want MemberIDRequired", err)
657+
}
658+
if len(gotMemberIDs) != 1 {
659+
t.Errorf("got %d joinGroup calls, want 1 (no retry without assigned ID): %v",
660+
len(gotMemberIDs), gotMemberIDs)
661+
}
662+
},
663+
},
664+
{
665+
scenario: "repeated MEMBER_ID_REQUIRED is capped at one retry",
666+
joinResponses: []joinGroupResponse{
667+
// The mock repeats this entry on every call, simulating a broker
668+
// that violates KIP-394 by re-requesting a member ID we already
669+
// echoed back. The cap in joinGroup must prevent an infinite loop.
670+
{ErrorCode: int16(MemberIDRequired), MemberID: "kip394-assigned"},
671+
},
672+
assertFn: func(t *testing.T, err error, gotMemberIDs []string) {
673+
if !errors.Is(err, MemberIDRequired) {
674+
t.Errorf("Next err = %v, want MemberIDRequired", err)
675+
}
676+
if len(gotMemberIDs) != 2 {
677+
t.Errorf("got %d joinGroup calls, want 2 (handshake + cap): %v",
678+
len(gotMemberIDs), gotMemberIDs)
679+
}
680+
},
681+
},
682+
}
683+
684+
for _, tt := range tests {
685+
t.Run(tt.scenario, func(t *testing.T) {
686+
var (
687+
lock sync.Mutex
688+
gotMemberIDs []string
689+
)
690+
mc := mockCoordinator{
691+
findCoordinatorFunc: func(findCoordinatorRequestV0) (findCoordinatorResponseV0, error) {
692+
return coordinatorResp, nil
693+
},
694+
joinGroupFunc: func(req joinGroupRequest) (joinGroupResponse, error) {
695+
lock.Lock()
696+
gotMemberIDs = append(gotMemberIDs, req.MemberID)
697+
n := len(gotMemberIDs)
698+
lock.Unlock()
699+
if n <= len(tt.joinResponses) {
700+
return tt.joinResponses[n-1], nil
701+
}
702+
return tt.joinResponses[len(tt.joinResponses)-1], nil
703+
},
704+
syncGroupFunc: func(syncGroupRequestV0) (syncGroupResponseV0, error) {
705+
return syncGroupResponseV0{}, errors.New("sync intentionally failed")
706+
},
707+
readPartitionsFunc: func(...string) ([]Partition, error) {
708+
return nil, nil
709+
},
710+
leaveGroupFunc: func(leaveGroupRequestV0) (leaveGroupResponseV0, error) {
711+
return leaveGroupResponseV0{}, nil
712+
},
713+
}
714+
715+
group, err := NewConsumerGroup(ConsumerGroupConfig{
716+
ID: makeGroupID(),
717+
Topics: []string{"test"},
718+
Brokers: []string{"no-such-broker"},
719+
HeartbeatInterval: 2 * time.Second,
720+
RebalanceTimeout: time.Second,
721+
// Long backoff so a second run-loop iteration cannot pollute
722+
// gotMemberIDs before the assertions complete.
723+
JoinGroupBackoff: 30 * time.Second,
724+
RetentionTime: time.Hour,
725+
connect: func(*Dialer, ...string) (coordinator, error) {
726+
return mc, nil
727+
},
728+
Logger: &testKafkaLogger{T: t},
729+
})
730+
if err != nil {
731+
t.Fatal(err)
732+
}
733+
defer group.Close()
734+
735+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
736+
defer cancel()
737+
738+
_, nextErr := group.Next(ctx)
739+
740+
lock.Lock()
741+
seq := append([]string(nil), gotMemberIDs...)
742+
lock.Unlock()
743+
tt.assertFn(t, nextErr, seq)
744+
})
745+
}
746+
}
747+
599748
func TestGenerationExitsOnPartitionChange(t *testing.T) {
600749
var count int
601750
partitions := [][]Partition{
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Tansu — Apache Kafka®-compatible broker (Rust, single binary).
2+
# Used to regression-test KIP-394 (MEMBER_ID_REQUIRED handshake) since
3+
# Tansu enforces it strictly across JoinGroup versions, unlike Apache Kafka
4+
# which only enforces on v4+.
5+
#
6+
# Known semantic differences vs. Apache Kafka (see `ktesting.IsTansu` for
7+
# the canonical list and the test branches gated on it):
8+
# - generation_id is not bumped on soft rejoin of an existing dynamic
9+
# member; Apache Kafka bumps on every JoinGroup from an existing member.
10+
# Run tests with KAFKA_VERSION=tansu-0.6.0 so the affected assertions
11+
# downgrade to log lines instead of failures.
12+
#
13+
# See:
14+
# https://github.com/tansu-io/tansu
15+
# https://github.com/segmentio/kafka-go/issues/1432
16+
version: '3'
17+
services:
18+
tansu:
19+
container_name: tansu
20+
image: ghcr.io/tansu-io/tansu:0.6.0
21+
restart: on-failure:3
22+
ports:
23+
- 9092:9092
24+
environment:
25+
RUST_LOG: info
26+
# All flags below match the defaults documented by `tansu --help`,
27+
# but are spelled out explicitly so the compose file documents the
28+
# broker's exposed surface for kafka-go tests.
29+
command:
30+
- --kafka-cluster-id=kafka-go-tansu-test
31+
- --kafka-listener-url=tcp://0.0.0.0:9092/
32+
- --kafka-advertised-listener-url=tcp://localhost:9092/
33+
- --storage-engine=memory://tansu/

scripts/wait-for-kafka.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
#/bin/bash
22

33
COUNTER=0;
4-
echo foo | nc localhost 9092
4+
nc -z -w 2 localhost 9092
55
STATUS=$?
66
ATTEMPTS=60
77
until [ ${STATUS} -eq 0 ] || [ "$COUNTER" -ge "${ATTEMPTS}" ];
88
do
99
let COUNTER=$COUNTER+1;
1010
sleep 1;
1111
echo "[$COUNTER] waiting for 9092 port to be open";
12-
echo foo | nc localhost 9092
12+
nc -z -w 2 localhost 9092
1313
STATUS=$?
1414
done
1515

testing/version.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,23 @@ func (v semver) atLeast(other semver) bool {
2929
}
3030

3131
// kafkaVersion is set in the circle config. It can also be provided on the
32-
// command line in order to target a particular kafka version.
33-
var kafkaVersion = parseVersion(os.Getenv("KAFKA_VERSION"))
32+
// command line in order to target a particular kafka version. Non-numeric
33+
// values (e.g., "tansu-0.6.0") are tolerated and parsed as an empty semver
34+
// so that init does not panic when running against alternative brokers — use
35+
// IsTansu() to detect those cases.
36+
var kafkaVersion = parseEnvKafkaVersion(os.Getenv("KAFKA_VERSION"))
37+
38+
func parseEnvKafkaVersion(v string) semver {
39+
if v == "" {
40+
return nil
41+
}
42+
for _, ch := range v {
43+
if ch != '.' && (ch < '0' || ch > '9') {
44+
return nil
45+
}
46+
}
47+
return parseVersion(v)
48+
}
3449

3550
// KafkaIsAtLeast returns true when the test broker is running a protocol
3651
// version that is semver or newer. It determines the broker's version using
@@ -40,6 +55,22 @@ func KafkaIsAtLeast(semver string) bool {
4055
return kafkaVersion.atLeast(parseVersion(semver))
4156
}
4257

58+
// IsTansu reports whether tests are running against Tansu, a Kafka-compatible
59+
// broker with some semantic differences from Apache Kafka:
60+
//
61+
// - Enforces KIP-394 (MEMBER_ID_REQUIRED) across all JoinGroup versions,
62+
// not only v4+ as the KIP prescribes.
63+
// - Does not bump the consumer group generation_id on soft rejoin of an
64+
// existing dynamic member; it only increments when the group composition
65+
// actually changes. Apache Kafka bumps on every JoinGroup from an
66+
// existing member.
67+
//
68+
// Set `KAFKA_VERSION` to a value beginning with "tansu" (e.g., "tansu-0.6.0")
69+
// to opt into Tansu-aware test branches.
70+
func IsTansu() bool {
71+
return strings.HasPrefix(os.Getenv("KAFKA_VERSION"), "tansu")
72+
}
73+
4374
func parseVersion(semver string) semver {
4475
if semver == "" {
4576
return nil

0 commit comments

Comments
 (0)