|
9 | 9 | "sync" |
10 | 10 | "testing" |
11 | 11 | "time" |
| 12 | + |
| 13 | + ktesting "github.com/segmentio/kafka-go/testing" |
12 | 14 | ) |
13 | 15 |
|
14 | 16 | var _ coordinator = mockCoordinator{} |
@@ -271,7 +273,14 @@ func TestConsumerGroup(t *testing.T) { |
271 | 273 | } |
272 | 274 |
|
273 | 275 | 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 | + } |
275 | 284 | } |
276 | 285 | if gen1.GroupID != gen2.GroupID { |
277 | 286 | t.Errorf("mismatched group ID between generations: %s and %s", gen1.GroupID, gen2.GroupID) |
@@ -596,6 +605,146 @@ func TestConsumerGroupErrors(t *testing.T) { |
596 | 605 |
|
597 | 606 | // todo : test for multi-topic? |
598 | 607 |
|
| 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 | + |
599 | 748 | func TestGenerationExitsOnPartitionChange(t *testing.T) { |
600 | 749 | var count int |
601 | 750 | partitions := [][]Partition{ |
|
0 commit comments