snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
package consumer
import (
"sort"
)
// Assignment strategy protocol names
const (
ProtocolNameRange = "range"
ProtocolNameRoundRobin = "roundrobin"
ProtocolNameSticky = "sticky"
ProtocolNameCooperativeSticky = "cooperative-sticky"
)
// AssignmentStrategy defines how partitions are assigned to consumers
type AssignmentStrategy interface {
Name() string
Assign(members []*GroupMember, topicPartitions map[string][]int32) map[string][]PartitionAssignment
}
// RangeAssignmentStrategy implements the Range assignment strategy
// Assigns partitions in ranges to consumers, similar to Kafka's range assignor
type RangeAssignmentStrategy struct{}
func (r *RangeAssignmentStrategy) Name() string {
return ProtocolNameRange
}
func (r *RangeAssignmentStrategy) Assign(members []*GroupMember, topicPartitions map[string][]int32) map[string][]PartitionAssignment {
if len(members) == 0 {
return make(map[string][]PartitionAssignment)
}
assignments := make(map[string][]PartitionAssignment)
for _, member := range members {
assignments[member.ID] = make([]PartitionAssignment, 0)
}
// Sort members for consistent assignment
sortedMembers := make([]*GroupMember, len(members))
copy(sortedMembers, members)
sort.Slice(sortedMembers, func(i, j int) bool {
return sortedMembers[i].ID < sortedMembers[j].ID
})
// Get all subscribed topics
subscribedTopics := make(map[string]bool)
for _, member := range members {
for _, topic := range member.Subscription {
subscribedTopics[topic] = true
}
}
// Assign partitions for each topic
for topic := range subscribedTopics {
partitions, exists := topicPartitions[topic]
if !exists {
continue
}
// Sort partitions for consistent assignment
sort.Slice(partitions, func(i, j int) bool {
return partitions[i] < partitions[j]
})
// Find members subscribed to this topic
topicMembers := make([]*GroupMember, 0)
for _, member := range sortedMembers {
for _, subscribedTopic := range member.Subscription {
if subscribedTopic == topic {
topicMembers = append(topicMembers, member)
break
}
}
}
if len(topicMembers) == 0 {
continue
}
// Assign partitions to members using range strategy
numPartitions := len(partitions)
numMembers := len(topicMembers)
partitionsPerMember := numPartitions / numMembers
remainingPartitions := numPartitions % numMembers
partitionIndex := 0
for memberIndex, member := range topicMembers {
// Calculate how many partitions this member should get
memberPartitions := partitionsPerMember
if memberIndex < remainingPartitions {
memberPartitions++
}
// Assign partitions to this member
for i := 0; i < memberPartitions && partitionIndex < numPartitions; i++ {
assignment := PartitionAssignment{
Topic: topic,
Partition: partitions[partitionIndex],
}
assignments[member.ID] = append(assignments[member.ID], assignment)
partitionIndex++
}
}
}
return assignments
}
// RoundRobinAssignmentStrategy implements the RoundRobin assignment strategy
// Distributes partitions evenly across all consumers in round-robin fashion
type RoundRobinAssignmentStrategy struct{}
func (rr *RoundRobinAssignmentStrategy) Name() string {
return ProtocolNameRoundRobin
}
func (rr *RoundRobinAssignmentStrategy) Assign(members []*GroupMember, topicPartitions map[string][]int32) map[string][]PartitionAssignment {
if len(members) == 0 {
return make(map[string][]PartitionAssignment)
}
assignments := make(map[string][]PartitionAssignment)
for _, member := range members {
assignments[member.ID] = make([]PartitionAssignment, 0)
}
// Sort members for consistent assignment
sortedMembers := make([]*GroupMember, len(members))
copy(sortedMembers, members)
sort.Slice(sortedMembers, func(i, j int) bool {
return sortedMembers[i].ID < sortedMembers[j].ID
})
// Collect all partition assignments across all topics
allAssignments := make([]PartitionAssignment, 0)
// Get all subscribed topics
subscribedTopics := make(map[string]bool)
for _, member := range members {
for _, topic := range member.Subscription {
subscribedTopics[topic] = true
}
}
// Collect all partitions from all subscribed topics
for topic := range subscribedTopics {
partitions, exists := topicPartitions[topic]
if !exists {
continue
}
for _, partition := range partitions {
allAssignments = append(allAssignments, PartitionAssignment{
Topic: topic,
Partition: partition,
})
}
}
// Sort assignments for consistent distribution
sort.Slice(allAssignments, func(i, j int) bool {
if allAssignments[i].Topic != allAssignments[j].Topic {
return allAssignments[i].Topic < allAssignments[j].Topic
}
return allAssignments[i].Partition < allAssignments[j].Partition
})
// Distribute partitions in round-robin fashion
memberIndex := 0
for _, assignment := range allAssignments {
// Find a member that is subscribed to this topic
assigned := false
startIndex := memberIndex
for !assigned {
member := sortedMembers[memberIndex]
// Check if this member is subscribed to the topic
subscribed := false
for _, topic := range member.Subscription {
if topic == assignment.Topic {
subscribed = true
break
}
}
if subscribed {
assignments[member.ID] = append(assignments[member.ID], assignment)
assigned = true
}
memberIndex = (memberIndex + 1) % len(sortedMembers)
// Prevent infinite loop if no member is subscribed to this topic
if memberIndex == startIndex && !assigned {
break
}
}
}
return assignments
}
// GetAssignmentStrategy returns the appropriate assignment strategy
func GetAssignmentStrategy(name string) AssignmentStrategy {
switch name {
case ProtocolNameRange:
return &RangeAssignmentStrategy{}
case ProtocolNameRoundRobin:
return &RoundRobinAssignmentStrategy{}
case ProtocolNameCooperativeSticky:
return NewIncrementalCooperativeAssignmentStrategy()
default:
// Default to range strategy
return &RangeAssignmentStrategy{}
}
}
// AssignPartitions performs partition assignment for a consumer group
func (group *ConsumerGroup) AssignPartitions(topicPartitions map[string][]int32) {
if len(group.Members) == 0 {
return
}
// Convert members map to slice
members := make([]*GroupMember, 0, len(group.Members))
for _, member := range group.Members {
if member.State == MemberStateStable || member.State == MemberStatePending {
members = append(members, member)
}
}
if len(members) == 0 {
return
}
// Get assignment strategy
strategy := GetAssignmentStrategy(group.Protocol)
assignments := strategy.Assign(members, topicPartitions)
// Apply assignments to members
for memberID, assignment := range assignments {
if member, exists := group.Members[memberID]; exists {
member.Assignment = assignment
}
}
}
// GetMemberAssignments returns the current partition assignments for all members
func (group *ConsumerGroup) GetMemberAssignments() map[string][]PartitionAssignment {
group.Mu.RLock()
defer group.Mu.RUnlock()
assignments := make(map[string][]PartitionAssignment)
for memberID, member := range group.Members {
assignments[memberID] = make([]PartitionAssignment, len(member.Assignment))
copy(assignments[memberID], member.Assignment)
}
return assignments
}
// UpdateMemberSubscription updates a member's topic subscription
func (group *ConsumerGroup) UpdateMemberSubscription(memberID string, topics []string) {
group.Mu.Lock()
defer group.Mu.Unlock()
member, exists := group.Members[memberID]
if !exists {
return
}
// Update member subscription
member.Subscription = make([]string, len(topics))
copy(member.Subscription, topics)
// Update group's subscribed topics
group.SubscribedTopics = make(map[string]bool)
for _, m := range group.Members {
for _, topic := range m.Subscription {
group.SubscribedTopics[topic] = true
}
}
}
// GetSubscribedTopics returns all topics subscribed by the group
func (group *ConsumerGroup) GetSubscribedTopics() []string {
group.Mu.RLock()
defer group.Mu.RUnlock()
topics := make([]string, 0, len(group.SubscribedTopics))
for topic := range group.SubscribedTopics {
topics = append(topics, topic)
}
sort.Strings(topics)
return topics
}

View File

@@ -0,0 +1,359 @@
package consumer
import (
"reflect"
"sort"
"testing"
)
func TestRangeAssignmentStrategy(t *testing.T) {
strategy := &RangeAssignmentStrategy{}
if strategy.Name() != ProtocolNameRange {
t.Errorf("Expected strategy name '%s', got '%s'", ProtocolNameRange, strategy.Name())
}
// Test with 2 members, 4 partitions on one topic
members := []*GroupMember{
{
ID: "member1",
Subscription: []string{"topic1"},
},
{
ID: "member2",
Subscription: []string{"topic1"},
},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
assignments := strategy.Assign(members, topicPartitions)
// Verify all members have assignments
if len(assignments) != 2 {
t.Fatalf("Expected assignments for 2 members, got %d", len(assignments))
}
// Verify total partitions assigned
totalAssigned := 0
for _, assignment := range assignments {
totalAssigned += len(assignment)
}
if totalAssigned != 4 {
t.Errorf("Expected 4 total partitions assigned, got %d", totalAssigned)
}
// Range assignment should distribute evenly: 2 partitions each
for memberID, assignment := range assignments {
if len(assignment) != 2 {
t.Errorf("Expected 2 partitions for member %s, got %d", memberID, len(assignment))
}
// Verify all assignments are for the subscribed topic
for _, pa := range assignment {
if pa.Topic != "topic1" {
t.Errorf("Expected topic 'topic1', got '%s'", pa.Topic)
}
}
}
}
func TestRangeAssignmentStrategy_UnevenPartitions(t *testing.T) {
strategy := &RangeAssignmentStrategy{}
// Test with 3 members, 4 partitions - should distribute 2,1,1
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1"}},
{ID: "member2", Subscription: []string{"topic1"}},
{ID: "member3", Subscription: []string{"topic1"}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
assignments := strategy.Assign(members, topicPartitions)
// Get assignment counts
counts := make([]int, 0, 3)
for _, assignment := range assignments {
counts = append(counts, len(assignment))
}
sort.Ints(counts)
// Should be distributed as [1, 1, 2] (first member gets extra partition)
expected := []int{1, 1, 2}
if !reflect.DeepEqual(counts, expected) {
t.Errorf("Expected partition distribution %v, got %v", expected, counts)
}
}
func TestRangeAssignmentStrategy_MultipleTopics(t *testing.T) {
strategy := &RangeAssignmentStrategy{}
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1", "topic2"}},
{ID: "member2", Subscription: []string{"topic1"}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1},
"topic2": {0, 1},
}
assignments := strategy.Assign(members, topicPartitions)
// Member1 should get assignments from both topics
member1Assignments := assignments["member1"]
topicsAssigned := make(map[string]int)
for _, pa := range member1Assignments {
topicsAssigned[pa.Topic]++
}
if len(topicsAssigned) != 2 {
t.Errorf("Expected member1 to be assigned to 2 topics, got %d", len(topicsAssigned))
}
// Member2 should only get topic1 assignments
member2Assignments := assignments["member2"]
for _, pa := range member2Assignments {
if pa.Topic != "topic1" {
t.Errorf("Expected member2 to only get topic1, but got %s", pa.Topic)
}
}
}
func TestRoundRobinAssignmentStrategy(t *testing.T) {
strategy := &RoundRobinAssignmentStrategy{}
if strategy.Name() != ProtocolNameRoundRobin {
t.Errorf("Expected strategy name '%s', got '%s'", ProtocolNameRoundRobin, strategy.Name())
}
// Test with 2 members, 4 partitions on one topic
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1"}},
{ID: "member2", Subscription: []string{"topic1"}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
assignments := strategy.Assign(members, topicPartitions)
// Verify all members have assignments
if len(assignments) != 2 {
t.Fatalf("Expected assignments for 2 members, got %d", len(assignments))
}
// Verify total partitions assigned
totalAssigned := 0
for _, assignment := range assignments {
totalAssigned += len(assignment)
}
if totalAssigned != 4 {
t.Errorf("Expected 4 total partitions assigned, got %d", totalAssigned)
}
// Round robin should distribute evenly: 2 partitions each
for memberID, assignment := range assignments {
if len(assignment) != 2 {
t.Errorf("Expected 2 partitions for member %s, got %d", memberID, len(assignment))
}
}
}
func TestRoundRobinAssignmentStrategy_MultipleTopics(t *testing.T) {
strategy := &RoundRobinAssignmentStrategy{}
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1", "topic2"}},
{ID: "member2", Subscription: []string{"topic1", "topic2"}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1},
"topic2": {0, 1},
}
assignments := strategy.Assign(members, topicPartitions)
// Each member should get 2 partitions (round robin across topics)
for memberID, assignment := range assignments {
if len(assignment) != 2 {
t.Errorf("Expected 2 partitions for member %s, got %d", memberID, len(assignment))
}
}
// Verify no partition is assigned twice
assignedPartitions := make(map[string]map[int32]bool)
for _, assignment := range assignments {
for _, pa := range assignment {
if assignedPartitions[pa.Topic] == nil {
assignedPartitions[pa.Topic] = make(map[int32]bool)
}
if assignedPartitions[pa.Topic][pa.Partition] {
t.Errorf("Partition %d of topic %s assigned multiple times", pa.Partition, pa.Topic)
}
assignedPartitions[pa.Topic][pa.Partition] = true
}
}
}
func TestGetAssignmentStrategy(t *testing.T) {
rangeStrategy := GetAssignmentStrategy(ProtocolNameRange)
if rangeStrategy.Name() != ProtocolNameRange {
t.Errorf("Expected range strategy, got %s", rangeStrategy.Name())
}
rrStrategy := GetAssignmentStrategy(ProtocolNameRoundRobin)
if rrStrategy.Name() != ProtocolNameRoundRobin {
t.Errorf("Expected roundrobin strategy, got %s", rrStrategy.Name())
}
// Unknown strategy should default to range
defaultStrategy := GetAssignmentStrategy("unknown")
if defaultStrategy.Name() != ProtocolNameRange {
t.Errorf("Expected default strategy to be range, got %s", defaultStrategy.Name())
}
}
func TestConsumerGroup_AssignPartitions(t *testing.T) {
group := &ConsumerGroup{
ID: "test-group",
Protocol: ProtocolNameRange,
Members: map[string]*GroupMember{
"member1": {
ID: "member1",
Subscription: []string{"topic1"},
State: MemberStateStable,
},
"member2": {
ID: "member2",
Subscription: []string{"topic1"},
State: MemberStateStable,
},
},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
group.AssignPartitions(topicPartitions)
// Verify assignments were created
for memberID, member := range group.Members {
if len(member.Assignment) == 0 {
t.Errorf("Expected member %s to have partition assignments", memberID)
}
// Verify all assignments are valid
for _, pa := range member.Assignment {
if pa.Topic != "topic1" {
t.Errorf("Unexpected topic assignment: %s", pa.Topic)
}
if pa.Partition < 0 || pa.Partition >= 4 {
t.Errorf("Unexpected partition assignment: %d", pa.Partition)
}
}
}
}
func TestConsumerGroup_GetMemberAssignments(t *testing.T) {
group := &ConsumerGroup{
Members: map[string]*GroupMember{
"member1": {
ID: "member1",
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 0},
{Topic: "topic1", Partition: 1},
},
},
},
}
assignments := group.GetMemberAssignments()
if len(assignments) != 1 {
t.Fatalf("Expected 1 member assignment, got %d", len(assignments))
}
member1Assignments := assignments["member1"]
if len(member1Assignments) != 2 {
t.Errorf("Expected 2 partition assignments for member1, got %d", len(member1Assignments))
}
// Verify assignment content
expectedAssignments := []PartitionAssignment{
{Topic: "topic1", Partition: 0},
{Topic: "topic1", Partition: 1},
}
if !reflect.DeepEqual(member1Assignments, expectedAssignments) {
t.Errorf("Expected assignments %v, got %v", expectedAssignments, member1Assignments)
}
}
func TestConsumerGroup_UpdateMemberSubscription(t *testing.T) {
group := &ConsumerGroup{
Members: map[string]*GroupMember{
"member1": {
ID: "member1",
Subscription: []string{"topic1"},
},
"member2": {
ID: "member2",
Subscription: []string{"topic2"},
},
},
SubscribedTopics: map[string]bool{
"topic1": true,
"topic2": true,
},
}
// Update member1's subscription
group.UpdateMemberSubscription("member1", []string{"topic1", "topic3"})
// Verify member subscription updated
member1 := group.Members["member1"]
expectedSubscription := []string{"topic1", "topic3"}
if !reflect.DeepEqual(member1.Subscription, expectedSubscription) {
t.Errorf("Expected subscription %v, got %v", expectedSubscription, member1.Subscription)
}
// Verify group subscribed topics updated
expectedGroupTopics := []string{"topic1", "topic2", "topic3"}
actualGroupTopics := group.GetSubscribedTopics()
if !reflect.DeepEqual(actualGroupTopics, expectedGroupTopics) {
t.Errorf("Expected group topics %v, got %v", expectedGroupTopics, actualGroupTopics)
}
}
func TestAssignmentStrategy_EmptyMembers(t *testing.T) {
rangeStrategy := &RangeAssignmentStrategy{}
rrStrategy := &RoundRobinAssignmentStrategy{}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
// Both strategies should handle empty members gracefully
rangeAssignments := rangeStrategy.Assign([]*GroupMember{}, topicPartitions)
rrAssignments := rrStrategy.Assign([]*GroupMember{}, topicPartitions)
if len(rangeAssignments) != 0 {
t.Error("Expected empty assignments for empty members list (range)")
}
if len(rrAssignments) != 0 {
t.Error("Expected empty assignments for empty members list (round robin)")
}
}

View File

@@ -0,0 +1,423 @@
package consumer
import (
"testing"
)
func TestCooperativeStickyAssignmentStrategy_Name(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
if strategy.Name() != ProtocolNameCooperativeSticky {
t.Errorf("Expected strategy name '%s', got '%s'", ProtocolNameCooperativeSticky, strategy.Name())
}
}
func TestCooperativeStickyAssignmentStrategy_InitialAssignment(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1"}, Assignment: []PartitionAssignment{}},
{ID: "member2", Subscription: []string{"topic1"}, Assignment: []PartitionAssignment{}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
assignments := strategy.Assign(members, topicPartitions)
// Verify all partitions are assigned
totalAssigned := 0
for _, assignment := range assignments {
totalAssigned += len(assignment)
}
if totalAssigned != 4 {
t.Errorf("Expected 4 total partitions assigned, got %d", totalAssigned)
}
// Verify fair distribution (2 partitions each)
for memberID, assignment := range assignments {
if len(assignment) != 2 {
t.Errorf("Expected member %s to get 2 partitions, got %d", memberID, len(assignment))
}
}
// Verify no partition is assigned twice
assignedPartitions := make(map[PartitionAssignment]bool)
for _, assignment := range assignments {
for _, pa := range assignment {
if assignedPartitions[pa] {
t.Errorf("Partition %v assigned multiple times", pa)
}
assignedPartitions[pa] = true
}
}
}
func TestCooperativeStickyAssignmentStrategy_StickyBehavior(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Initial state: member1 has partitions 0,1 and member2 has partitions 2,3
members := []*GroupMember{
{
ID: "member1",
Subscription: []string{"topic1"},
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 0},
{Topic: "topic1", Partition: 1},
},
},
{
ID: "member2",
Subscription: []string{"topic1"},
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 2},
{Topic: "topic1", Partition: 3},
},
},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
assignments := strategy.Assign(members, topicPartitions)
// Verify sticky behavior - existing assignments should be preserved
member1Assignment := assignments["member1"]
member2Assignment := assignments["member2"]
// Check that member1 still has partitions 0 and 1
hasPartition0 := false
hasPartition1 := false
for _, pa := range member1Assignment {
if pa.Topic == "topic1" && pa.Partition == 0 {
hasPartition0 = true
}
if pa.Topic == "topic1" && pa.Partition == 1 {
hasPartition1 = true
}
}
if !hasPartition0 || !hasPartition1 {
t.Errorf("Member1 should retain partitions 0 and 1, got %v", member1Assignment)
}
// Check that member2 still has partitions 2 and 3
hasPartition2 := false
hasPartition3 := false
for _, pa := range member2Assignment {
if pa.Topic == "topic1" && pa.Partition == 2 {
hasPartition2 = true
}
if pa.Topic == "topic1" && pa.Partition == 3 {
hasPartition3 = true
}
}
if !hasPartition2 || !hasPartition3 {
t.Errorf("Member2 should retain partitions 2 and 3, got %v", member2Assignment)
}
}
func TestCooperativeStickyAssignmentStrategy_NewMemberJoin(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Scenario: member1 has all partitions, member2 joins
members := []*GroupMember{
{
ID: "member1",
Subscription: []string{"topic1"},
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 0},
{Topic: "topic1", Partition: 1},
{Topic: "topic1", Partition: 2},
{Topic: "topic1", Partition: 3},
},
},
{
ID: "member2",
Subscription: []string{"topic1"},
Assignment: []PartitionAssignment{}, // New member, no existing assignment
},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3},
}
// First call: revocation phase
assignments1 := strategy.Assign(members, topicPartitions)
// Update members with revocation results
members[0].Assignment = assignments1["member1"]
members[1].Assignment = assignments1["member2"]
// Force completion of revocation timeout
strategy.GetRebalanceState().RevocationTimeout = 0
// Second call: assignment phase
assignments := strategy.Assign(members, topicPartitions)
// Verify fair redistribution (2 partitions each)
member1Assignment := assignments["member1"]
member2Assignment := assignments["member2"]
if len(member1Assignment) != 2 {
t.Errorf("Expected member1 to have 2 partitions after rebalance, got %d", len(member1Assignment))
}
if len(member2Assignment) != 2 {
t.Errorf("Expected member2 to have 2 partitions after rebalance, got %d", len(member2Assignment))
}
// Verify some stickiness - member1 should retain some of its original partitions
originalPartitions := map[int32]bool{0: true, 1: true, 2: true, 3: true}
retainedCount := 0
for _, pa := range member1Assignment {
if originalPartitions[pa.Partition] {
retainedCount++
}
}
if retainedCount == 0 {
t.Error("Member1 should retain at least some of its original partitions (sticky behavior)")
}
t.Logf("Member1 retained %d out of 4 original partitions", retainedCount)
}
func TestCooperativeStickyAssignmentStrategy_MemberLeave(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Scenario: member2 leaves, member1 should get its partitions
members := []*GroupMember{
{
ID: "member1",
Subscription: []string{"topic1"},
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 0},
{Topic: "topic1", Partition: 1},
},
},
// member2 has left, so it's not in the members list
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3}, // All partitions still need to be assigned
}
assignments := strategy.Assign(members, topicPartitions)
// member1 should get all partitions
member1Assignment := assignments["member1"]
if len(member1Assignment) != 4 {
t.Errorf("Expected member1 to get all 4 partitions after member2 left, got %d", len(member1Assignment))
}
// Verify member1 retained its original partitions (sticky behavior)
hasPartition0 := false
hasPartition1 := false
for _, pa := range member1Assignment {
if pa.Partition == 0 {
hasPartition0 = true
}
if pa.Partition == 1 {
hasPartition1 = true
}
}
if !hasPartition0 || !hasPartition1 {
t.Error("Member1 should retain its original partitions 0 and 1")
}
}
func TestCooperativeStickyAssignmentStrategy_MultipleTopics(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
members := []*GroupMember{
{
ID: "member1",
Subscription: []string{"topic1", "topic2"},
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 0},
{Topic: "topic2", Partition: 0},
},
},
{
ID: "member2",
Subscription: []string{"topic1", "topic2"},
Assignment: []PartitionAssignment{
{Topic: "topic1", Partition: 1},
{Topic: "topic2", Partition: 1},
},
},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1},
"topic2": {0, 1},
}
assignments := strategy.Assign(members, topicPartitions)
// Verify all partitions are assigned
totalAssigned := 0
for _, assignment := range assignments {
totalAssigned += len(assignment)
}
if totalAssigned != 4 {
t.Errorf("Expected 4 total partitions assigned across both topics, got %d", totalAssigned)
}
// Verify sticky behavior - each member should retain their original assignments
member1Assignment := assignments["member1"]
member2Assignment := assignments["member2"]
// Check member1 retains topic1:0 and topic2:0
hasT1P0 := false
hasT2P0 := false
for _, pa := range member1Assignment {
if pa.Topic == "topic1" && pa.Partition == 0 {
hasT1P0 = true
}
if pa.Topic == "topic2" && pa.Partition == 0 {
hasT2P0 = true
}
}
if !hasT1P0 || !hasT2P0 {
t.Errorf("Member1 should retain topic1:0 and topic2:0, got %v", member1Assignment)
}
// Check member2 retains topic1:1 and topic2:1
hasT1P1 := false
hasT2P1 := false
for _, pa := range member2Assignment {
if pa.Topic == "topic1" && pa.Partition == 1 {
hasT1P1 = true
}
if pa.Topic == "topic2" && pa.Partition == 1 {
hasT2P1 = true
}
}
if !hasT1P1 || !hasT2P1 {
t.Errorf("Member2 should retain topic1:1 and topic2:1, got %v", member2Assignment)
}
}
func TestCooperativeStickyAssignmentStrategy_UnevenPartitions(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// 5 partitions, 2 members - should distribute 3:2 or 2:3
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1"}, Assignment: []PartitionAssignment{}},
{ID: "member2", Subscription: []string{"topic1"}, Assignment: []PartitionAssignment{}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1, 2, 3, 4},
}
assignments := strategy.Assign(members, topicPartitions)
// Verify all partitions are assigned
totalAssigned := 0
for _, assignment := range assignments {
totalAssigned += len(assignment)
}
if totalAssigned != 5 {
t.Errorf("Expected 5 total partitions assigned, got %d", totalAssigned)
}
// Verify fair distribution
member1Count := len(assignments["member1"])
member2Count := len(assignments["member2"])
// Should be 3:2 or 2:3 distribution
if !((member1Count == 3 && member2Count == 2) || (member1Count == 2 && member2Count == 3)) {
t.Errorf("Expected 3:2 or 2:3 distribution, got %d:%d", member1Count, member2Count)
}
}
func TestCooperativeStickyAssignmentStrategy_PartialSubscription(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// member1 subscribes to both topics, member2 only to topic1
members := []*GroupMember{
{ID: "member1", Subscription: []string{"topic1", "topic2"}, Assignment: []PartitionAssignment{}},
{ID: "member2", Subscription: []string{"topic1"}, Assignment: []PartitionAssignment{}},
}
topicPartitions := map[string][]int32{
"topic1": {0, 1},
"topic2": {0, 1},
}
assignments := strategy.Assign(members, topicPartitions)
// member1 should get all topic2 partitions since member2 isn't subscribed
member1Assignment := assignments["member1"]
member2Assignment := assignments["member2"]
// Count topic2 partitions for each member
member1Topic2Count := 0
member2Topic2Count := 0
for _, pa := range member1Assignment {
if pa.Topic == "topic2" {
member1Topic2Count++
}
}
for _, pa := range member2Assignment {
if pa.Topic == "topic2" {
member2Topic2Count++
}
}
if member1Topic2Count != 2 {
t.Errorf("Expected member1 to get all 2 topic2 partitions, got %d", member1Topic2Count)
}
if member2Topic2Count != 0 {
t.Errorf("Expected member2 to get 0 topic2 partitions (not subscribed), got %d", member2Topic2Count)
}
// Both members should get some topic1 partitions
member1Topic1Count := 0
member2Topic1Count := 0
for _, pa := range member1Assignment {
if pa.Topic == "topic1" {
member1Topic1Count++
}
}
for _, pa := range member2Assignment {
if pa.Topic == "topic1" {
member2Topic1Count++
}
}
if member1Topic1Count+member2Topic1Count != 2 {
t.Errorf("Expected all topic1 partitions to be assigned, got %d + %d = %d",
member1Topic1Count, member2Topic1Count, member1Topic1Count+member2Topic1Count)
}
}
func TestGetAssignmentStrategy_CooperativeSticky(t *testing.T) {
strategy := GetAssignmentStrategy(ProtocolNameCooperativeSticky)
if strategy.Name() != ProtocolNameCooperativeSticky {
t.Errorf("Expected cooperative-sticky strategy, got %s", strategy.Name())
}
// Verify it's the correct type
if _, ok := strategy.(*IncrementalCooperativeAssignmentStrategy); !ok {
t.Errorf("Expected IncrementalCooperativeAssignmentStrategy, got %T", strategy)
}
}

View File

@@ -0,0 +1,447 @@
package consumer
import (
"crypto/sha256"
"fmt"
"sync"
"time"
)
// GroupState represents the state of a consumer group
type GroupState int
const (
GroupStateEmpty GroupState = iota
GroupStatePreparingRebalance
GroupStateCompletingRebalance
GroupStateStable
GroupStateDead
)
func (gs GroupState) String() string {
switch gs {
case GroupStateEmpty:
return "Empty"
case GroupStatePreparingRebalance:
return "PreparingRebalance"
case GroupStateCompletingRebalance:
return "CompletingRebalance"
case GroupStateStable:
return "Stable"
case GroupStateDead:
return "Dead"
default:
return "Unknown"
}
}
// MemberState represents the state of a group member
type MemberState int
const (
MemberStateUnknown MemberState = iota
MemberStatePending
MemberStateStable
MemberStateLeaving
)
func (ms MemberState) String() string {
switch ms {
case MemberStateUnknown:
return "Unknown"
case MemberStatePending:
return "Pending"
case MemberStateStable:
return "Stable"
case MemberStateLeaving:
return "Leaving"
default:
return "Unknown"
}
}
// GroupMember represents a consumer in a consumer group
type GroupMember struct {
ID string // Member ID (generated by gateway)
ClientID string // Client ID from consumer (raw Kafka header value; not unique across connections)
ConnectionID string // TCP connection identifier; distinguishes consumers that share a ClientID
ClientHost string // Client host/IP
GroupInstanceID *string // Static membership instance ID (optional)
SessionTimeout int32 // Session timeout in milliseconds
RebalanceTimeout int32 // Rebalance timeout in milliseconds
Subscription []string // Subscribed topics
Assignment []PartitionAssignment // Assigned partitions
Metadata []byte // Protocol-specific metadata
State MemberState // Current member state
LastHeartbeat time.Time // Last heartbeat timestamp
JoinedAt time.Time // When member joined group
}
// PartitionAssignment represents partition assignment for a member
type PartitionAssignment struct {
Topic string
Partition int32
}
// ConsumerGroup represents a Kafka consumer group
type ConsumerGroup struct {
ID string // Group ID
State GroupState // Current group state
Generation int32 // Generation ID (incremented on rebalance)
Protocol string // Assignment protocol (e.g., "range", "roundrobin")
Leader string // Leader member ID
Members map[string]*GroupMember // Group members by member ID
StaticMembers map[string]string // Static instance ID -> member ID mapping
SubscribedTopics map[string]bool // Topics subscribed by group
OffsetCommits map[string]map[int32]OffsetCommit // Topic -> Partition -> Offset
CreatedAt time.Time // Group creation time
LastActivity time.Time // Last activity (join, heartbeat, etc.)
Mu sync.RWMutex // Protects group state
}
// OffsetCommit represents a committed offset for a topic partition
type OffsetCommit struct {
Offset int64 // Committed offset
Metadata string // Optional metadata
Timestamp time.Time // Commit timestamp
}
// GroupCoordinator manages consumer groups
type GroupCoordinator struct {
groups map[string]*ConsumerGroup // Group ID -> Group
groupsMu sync.RWMutex // Protects groups map
// Configuration
sessionTimeoutMin int32 // Minimum session timeout (ms)
sessionTimeoutMax int32 // Maximum session timeout (ms)
rebalanceTimeoutMs int32 // Default rebalance timeout (ms)
// Timeout management
rebalanceTimeoutManager *RebalanceTimeoutManager
// Cleanup
cleanupTicker *time.Ticker
stopChan chan struct{}
stopOnce sync.Once
}
// NewGroupCoordinator creates a new consumer group coordinator
func NewGroupCoordinator() *GroupCoordinator {
gc := &GroupCoordinator{
groups: make(map[string]*ConsumerGroup),
sessionTimeoutMin: 6000, // 6 seconds
sessionTimeoutMax: 300000, // 5 minutes
rebalanceTimeoutMs: 300000, // 5 minutes
stopChan: make(chan struct{}),
}
// Initialize rebalance timeout manager
gc.rebalanceTimeoutManager = NewRebalanceTimeoutManager(gc)
// Start cleanup routine
gc.cleanupTicker = time.NewTicker(30 * time.Second)
go gc.cleanupRoutine()
return gc
}
// GetOrCreateGroup returns an existing group or creates a new one
func (gc *GroupCoordinator) GetOrCreateGroup(groupID string) *ConsumerGroup {
gc.groupsMu.Lock()
defer gc.groupsMu.Unlock()
group, exists := gc.groups[groupID]
if !exists {
group = &ConsumerGroup{
ID: groupID,
State: GroupStateEmpty,
Generation: 0,
Members: make(map[string]*GroupMember),
StaticMembers: make(map[string]string),
SubscribedTopics: make(map[string]bool),
OffsetCommits: make(map[string]map[int32]OffsetCommit),
CreatedAt: time.Now(),
LastActivity: time.Now(),
}
gc.groups[groupID] = group
}
return group
}
// GetGroup returns an existing group or nil if not found
func (gc *GroupCoordinator) GetGroup(groupID string) *ConsumerGroup {
gc.groupsMu.RLock()
defer gc.groupsMu.RUnlock()
return gc.groups[groupID]
}
// RemoveGroup removes a group from the coordinator
func (gc *GroupCoordinator) RemoveGroup(groupID string) {
gc.groupsMu.Lock()
defer gc.groupsMu.Unlock()
delete(gc.groups, groupID)
}
// ListGroups returns all current group IDs
func (gc *GroupCoordinator) ListGroups() []string {
gc.groupsMu.RLock()
defer gc.groupsMu.RUnlock()
groups := make([]string, 0, len(gc.groups))
for groupID := range gc.groups {
groups = append(groups, groupID)
}
return groups
}
// FindStaticMember finds a member by static instance ID
func (gc *GroupCoordinator) FindStaticMember(group *ConsumerGroup, instanceID string) *GroupMember {
if instanceID == "" {
return nil
}
group.Mu.RLock()
defer group.Mu.RUnlock()
if memberID, exists := group.StaticMembers[instanceID]; exists {
return group.Members[memberID]
}
return nil
}
// FindStaticMemberLocked finds a member by static instance ID (assumes group is already locked)
func (gc *GroupCoordinator) FindStaticMemberLocked(group *ConsumerGroup, instanceID string) *GroupMember {
if instanceID == "" {
return nil
}
if memberID, exists := group.StaticMembers[instanceID]; exists {
return group.Members[memberID]
}
return nil
}
// RegisterStaticMember registers a static member in the group
func (gc *GroupCoordinator) RegisterStaticMember(group *ConsumerGroup, member *GroupMember) {
if member.GroupInstanceID == nil || *member.GroupInstanceID == "" {
return
}
group.Mu.Lock()
defer group.Mu.Unlock()
group.StaticMembers[*member.GroupInstanceID] = member.ID
}
// RegisterStaticMemberLocked registers a static member in the group (assumes group is already locked)
func (gc *GroupCoordinator) RegisterStaticMemberLocked(group *ConsumerGroup, member *GroupMember) {
if member.GroupInstanceID == nil || *member.GroupInstanceID == "" {
return
}
group.StaticMembers[*member.GroupInstanceID] = member.ID
}
// UnregisterStaticMember removes a static member from the group
func (gc *GroupCoordinator) UnregisterStaticMember(group *ConsumerGroup, instanceID string) {
if instanceID == "" {
return
}
group.Mu.Lock()
defer group.Mu.Unlock()
delete(group.StaticMembers, instanceID)
}
// UnregisterStaticMemberLocked removes a static member from the group (assumes group is already locked)
func (gc *GroupCoordinator) UnregisterStaticMemberLocked(group *ConsumerGroup, instanceID string) {
if instanceID == "" {
return
}
delete(group.StaticMembers, instanceID)
}
// IsStaticMember checks if a member is using static membership
func (gc *GroupCoordinator) IsStaticMember(member *GroupMember) bool {
return member.GroupInstanceID != nil && *member.GroupInstanceID != ""
}
// GenerateMemberID creates a deterministic member ID based on client info
func (gc *GroupCoordinator) GenerateMemberID(clientID, clientHost string) string {
// EXPERIMENT: Use simpler member ID format like real Kafka brokers
// Real Kafka uses format like: "consumer-1-uuid" or "consumer-groupId-uuid"
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(clientID+"-"+clientHost)))
return fmt.Sprintf("consumer-%s", hash[:16]) // Shorter, simpler format
}
// ValidateSessionTimeout checks if session timeout is within acceptable range
func (gc *GroupCoordinator) ValidateSessionTimeout(timeout int32) bool {
return timeout >= gc.sessionTimeoutMin && timeout <= gc.sessionTimeoutMax
}
// cleanupRoutine periodically cleans up dead groups and expired members
func (gc *GroupCoordinator) cleanupRoutine() {
for {
select {
case <-gc.cleanupTicker.C:
gc.performCleanup()
case <-gc.stopChan:
return
}
}
}
// EvictExpiredMembersLocked removes members whose session has expired and
// returns their IDs. The caller is responsible for any group-state transition
// that follows (e.g. moving the group to GroupStateEmpty when no members
// remain, or kicking off a rebalance among the survivors). The caller must
// hold group.Mu.
func (gc *GroupCoordinator) EvictExpiredMembersLocked(group *ConsumerGroup) []string {
now := time.Now()
var expired []string
for memberID, member := range group.Members {
// Defensive: a SessionTimeout of zero means the JoinGroup payload
// hasn't populated it yet, not "expired immediately".
if member.SessionTimeout <= 0 {
continue
}
sessionDuration := time.Duration(member.SessionTimeout) * time.Millisecond
if now.Sub(member.LastHeartbeat) <= sessionDuration {
continue
}
if member.GroupInstanceID != nil && *member.GroupInstanceID != "" {
delete(group.StaticMembers, *member.GroupInstanceID)
}
delete(group.Members, memberID)
if group.Leader == memberID {
group.Leader = ""
}
expired = append(expired, memberID)
}
return expired
}
// performCleanup removes expired members and empty groups
func (gc *GroupCoordinator) performCleanup() {
now := time.Now()
// Use rebalance timeout manager for more sophisticated timeout handling
gc.rebalanceTimeoutManager.CheckRebalanceTimeouts()
gc.groupsMu.Lock()
defer gc.groupsMu.Unlock()
for groupID, group := range gc.groups {
group.Mu.Lock()
// Evict expired members (session timeout). EvictExpiredMembersLocked
// also handles static-member unregistration and clears group.Leader if
// the leader was evicted.
expired := gc.EvictExpiredMembersLocked(group)
// If there are surviving members, mirror the join-time eviction path:
// move the group to PreparingRebalance, bump the generation, clear
// cached assignments, and select a new leader if one was evicted —
// otherwise the partitions owned by the expired member would stay
// assigned to that ghost slot until some unrelated join/leave bumped
// the group out of Stable. The join-time path doesn't need to pick a
// leader because it has an incoming member; here we have to.
if len(expired) > 0 && len(group.Members) > 0 {
if group.Leader == "" {
for memberID := range group.Members {
group.Leader = memberID
break
}
}
group.State = GroupStatePreparingRebalance
group.Generation++
for _, m := range group.Members {
m.State = MemberStatePending
m.Assignment = nil
}
// Rebuild subscribed topics from remaining members so a topic
// only the evicted member subscribed to is dropped.
group.SubscribedTopics = make(map[string]bool)
for _, m := range group.Members {
for _, topic := range m.Subscription {
group.SubscribedTopics[topic] = true
}
}
}
// Update group state based on member count
if len(group.Members) == 0 {
if group.State != GroupStateEmpty {
group.State = GroupStateEmpty
group.Generation++
}
// Mark group for deletion if empty for too long (30 minutes)
if now.Sub(group.LastActivity) > 30*time.Minute {
group.State = GroupStateDead
}
}
// Check for stuck rebalances and force completion if necessary
maxRebalanceDuration := 10 * time.Minute // Maximum time allowed for rebalancing
if gc.rebalanceTimeoutManager.IsRebalanceStuck(group, maxRebalanceDuration) {
gc.rebalanceTimeoutManager.ForceCompleteRebalance(group)
}
group.Mu.Unlock()
// Remove dead groups
if group.State == GroupStateDead {
delete(gc.groups, groupID)
}
}
}
// Close shuts down the group coordinator
func (gc *GroupCoordinator) Close() {
gc.stopOnce.Do(func() {
close(gc.stopChan)
if gc.cleanupTicker != nil {
gc.cleanupTicker.Stop()
}
})
}
// GetGroupStats returns statistics about the group coordinator
func (gc *GroupCoordinator) GetGroupStats() map[string]interface{} {
gc.groupsMu.RLock()
defer gc.groupsMu.RUnlock()
stats := map[string]interface{}{
"total_groups": len(gc.groups),
"group_states": make(map[string]int),
}
stateCount := make(map[GroupState]int)
totalMembers := 0
for _, group := range gc.groups {
group.Mu.RLock()
stateCount[group.State]++
totalMembers += len(group.Members)
group.Mu.RUnlock()
}
stats["total_members"] = totalMembers
for state, count := range stateCount {
stats["group_states"].(map[string]int)[state.String()] = count
}
return stats
}
// GetRebalanceStatus returns the rebalance status for a specific group
func (gc *GroupCoordinator) GetRebalanceStatus(groupID string) *RebalanceStatus {
return gc.rebalanceTimeoutManager.GetRebalanceStatus(groupID)
}

View File

@@ -0,0 +1,397 @@
package consumer
import (
"strings"
"testing"
"time"
)
func TestGroupCoordinator_CreateGroup(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
groupID := "test-group"
group := gc.GetOrCreateGroup(groupID)
if group == nil {
t.Fatal("Expected group to be created")
}
if group.ID != groupID {
t.Errorf("Expected group ID %s, got %s", groupID, group.ID)
}
if group.State != GroupStateEmpty {
t.Errorf("Expected initial state to be Empty, got %s", group.State)
}
if group.Generation != 0 {
t.Errorf("Expected initial generation to be 0, got %d", group.Generation)
}
// Getting the same group should return the existing one
group2 := gc.GetOrCreateGroup(groupID)
if group2 != group {
t.Error("Expected to get the same group instance")
}
}
func TestGroupCoordinator_ValidateSessionTimeout(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
// Test valid timeouts
validTimeouts := []int32{6000, 30000, 300000}
for _, timeout := range validTimeouts {
if !gc.ValidateSessionTimeout(timeout) {
t.Errorf("Expected timeout %d to be valid", timeout)
}
}
// Test invalid timeouts
invalidTimeouts := []int32{1000, 5000, 400000}
for _, timeout := range invalidTimeouts {
if gc.ValidateSessionTimeout(timeout) {
t.Errorf("Expected timeout %d to be invalid", timeout)
}
}
}
func TestGroupCoordinator_MemberManagement(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
// Add members
member1 := &GroupMember{
ID: "member1",
ClientID: "client1",
SessionTimeout: 30000,
Subscription: []string{"topic1", "topic2"},
State: MemberStateStable,
LastHeartbeat: time.Now(),
}
member2 := &GroupMember{
ID: "member2",
ClientID: "client2",
SessionTimeout: 30000,
Subscription: []string{"topic1"},
State: MemberStateStable,
LastHeartbeat: time.Now(),
}
group.Mu.Lock()
group.Members[member1.ID] = member1
group.Members[member2.ID] = member2
group.Mu.Unlock()
// Update subscriptions
group.UpdateMemberSubscription("member1", []string{"topic1", "topic3"})
group.Mu.RLock()
updatedMember := group.Members["member1"]
expectedTopics := []string{"topic1", "topic3"}
if len(updatedMember.Subscription) != len(expectedTopics) {
t.Errorf("Expected %d subscribed topics, got %d", len(expectedTopics), len(updatedMember.Subscription))
}
// Check group subscribed topics
if len(group.SubscribedTopics) != 2 { // topic1, topic3
t.Errorf("Expected 2 group subscribed topics, got %d", len(group.SubscribedTopics))
}
group.Mu.RUnlock()
}
func TestGroupCoordinator_Stats(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
// Create multiple groups in different states
group1 := gc.GetOrCreateGroup("group1")
group1.Mu.Lock()
group1.State = GroupStateStable
group1.Members["member1"] = &GroupMember{ID: "member1"}
group1.Members["member2"] = &GroupMember{ID: "member2"}
group1.Mu.Unlock()
group2 := gc.GetOrCreateGroup("group2")
group2.Mu.Lock()
group2.State = GroupStatePreparingRebalance
group2.Members["member3"] = &GroupMember{ID: "member3"}
group2.Mu.Unlock()
stats := gc.GetGroupStats()
totalGroups := stats["total_groups"].(int)
if totalGroups != 2 {
t.Errorf("Expected 2 total groups, got %d", totalGroups)
}
totalMembers := stats["total_members"].(int)
if totalMembers != 3 {
t.Errorf("Expected 3 total members, got %d", totalMembers)
}
stateCount := stats["group_states"].(map[string]int)
if stateCount["Stable"] != 1 {
t.Errorf("Expected 1 stable group, got %d", stateCount["Stable"])
}
if stateCount["PreparingRebalance"] != 1 {
t.Errorf("Expected 1 preparing rebalance group, got %d", stateCount["PreparingRebalance"])
}
}
func TestGroupCoordinator_Cleanup(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
// Create a group with an expired member
group := gc.GetOrCreateGroup("test-group")
expiredMember := &GroupMember{
ID: "expired-member",
SessionTimeout: 1000, // 1 second
LastHeartbeat: time.Now().Add(-2 * time.Second), // 2 seconds ago
State: MemberStateStable,
}
activeMember := &GroupMember{
ID: "active-member",
SessionTimeout: 30000, // 30 seconds
LastHeartbeat: time.Now(), // just now
State: MemberStateStable,
}
group.Mu.Lock()
group.Members[expiredMember.ID] = expiredMember
group.Members[activeMember.ID] = activeMember
group.Leader = expiredMember.ID // Make expired member the leader
group.Mu.Unlock()
// Perform cleanup
gc.performCleanup()
group.Mu.RLock()
defer group.Mu.RUnlock()
// Expired member should be removed
if _, exists := group.Members[expiredMember.ID]; exists {
t.Error("Expected expired member to be removed")
}
// Active member should remain
if _, exists := group.Members[activeMember.ID]; !exists {
t.Error("Expected active member to remain")
}
// Leader should be reset since expired member was leader
if group.Leader == expiredMember.ID {
t.Error("Expected leader to be reset after expired member removal")
}
}
func TestGroupCoordinator_GenerateMemberID(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
// Test that same client/host combination generates consistent member ID
id1 := gc.GenerateMemberID("client1", "host1")
id2 := gc.GenerateMemberID("client1", "host1")
// Same client/host should generate same ID (deterministic)
if id1 != id2 {
t.Errorf("Expected same member ID for same client/host: %s vs %s", id1, id2)
}
// Different clients should generate different IDs
id3 := gc.GenerateMemberID("client2", "host1")
id4 := gc.GenerateMemberID("client1", "host2")
if id1 == id3 {
t.Errorf("Expected different member IDs for different clients: %s vs %s", id1, id3)
}
if id1 == id4 {
t.Errorf("Expected different member IDs for different hosts: %s vs %s", id1, id4)
}
// IDs should be properly formatted
if len(id1) < 10 { // Should be longer than just "consumer-"
t.Errorf("Expected member ID to be properly formatted, got: %s", id1)
}
// Should start with "consumer-" prefix
if !strings.HasPrefix(id1, "consumer-") {
t.Errorf("Expected member ID to start with 'consumer-', got: %s", id1)
}
}
// TestGroupCoordinator_EvictExpiredMembersLocked covers the on-rejoin eviction
// path: JoinGroup calls this helper with the group lock held to drop phantom
// members whose session has expired but whose LeaveGroup never landed (the
// 30s cleanup tick is too coarse for fast consumer restarts with a 6s session
// timeout — see TestOffsetManagement/ConsumerGroupResumption).
func TestGroupCoordinator_EvictExpiredMembersLocked(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
expiredLeader := &GroupMember{
ID: "expired-leader",
SessionTimeout: 6000, // 6s
LastHeartbeat: time.Now().Add(-10 * time.Second), // 10s ago: expired
State: MemberStateStable,
}
staticInstance := "static-1"
expiredStatic := &GroupMember{
ID: "expired-static",
SessionTimeout: 6000,
LastHeartbeat: time.Now().Add(-30 * time.Second),
GroupInstanceID: &staticInstance,
State: MemberStateStable,
}
healthyMember := &GroupMember{
ID: "healthy-member",
SessionTimeout: 30000,
LastHeartbeat: time.Now(),
State: MemberStateStable,
}
group.Mu.Lock()
group.Members[expiredLeader.ID] = expiredLeader
group.Members[expiredStatic.ID] = expiredStatic
group.Members[healthyMember.ID] = healthyMember
group.StaticMembers[staticInstance] = expiredStatic.ID
group.Leader = expiredLeader.ID
evicted := gc.EvictExpiredMembersLocked(group)
defer group.Mu.Unlock()
if len(evicted) != 2 {
t.Fatalf("expected 2 evictions, got %d (%v)", len(evicted), evicted)
}
if _, exists := group.Members[expiredLeader.ID]; exists {
t.Errorf("expired leader should have been removed")
}
if _, exists := group.Members[expiredStatic.ID]; exists {
t.Errorf("expired static member should have been removed")
}
if _, exists := group.Members[healthyMember.ID]; !exists {
t.Errorf("healthy member should remain")
}
if group.Leader != "" {
t.Errorf("expected leader cleared after evicting old leader, got %q", group.Leader)
}
if _, exists := group.StaticMembers[staticInstance]; exists {
t.Errorf("expired static instance ID should have been unregistered")
}
}
// TestGroupCoordinator_Cleanup_SurvivorsRebalance covers the survivors path:
// when expired members are evicted but at least one healthy member remains,
// performCleanup must move the group to PreparingRebalance, bump the
// generation, clear cached assignments, and elect a new leader. Otherwise
// partitions held by the evicted member would stay assigned to the ghost
// slot until some unrelated join/leave bumped the group out of Stable.
func TestGroupCoordinator_Cleanup_SurvivorsRebalance(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
expiredLeader := &GroupMember{
ID: "expired-leader",
SessionTimeout: 1000, // 1s
LastHeartbeat: time.Now().Add(-5 * time.Second), // expired
Subscription: []string{"shared-topic", "leader-only"},
Assignment: []PartitionAssignment{{Topic: "shared-topic", Partition: 0}},
State: MemberStateStable,
}
survivor := &GroupMember{
ID: "survivor",
SessionTimeout: 30000,
LastHeartbeat: time.Now(),
Subscription: []string{"shared-topic"},
Assignment: []PartitionAssignment{{Topic: "shared-topic", Partition: 1}},
State: MemberStateStable,
}
group.Mu.Lock()
group.Members[expiredLeader.ID] = expiredLeader
group.Members[survivor.ID] = survivor
group.Leader = expiredLeader.ID
group.State = GroupStateStable
group.Generation = 5
group.SubscribedTopics = map[string]bool{
"shared-topic": true,
"leader-only": true,
}
group.Mu.Unlock()
gc.performCleanup()
group.Mu.RLock()
defer group.Mu.RUnlock()
if _, exists := group.Members[expiredLeader.ID]; exists {
t.Errorf("expired leader should have been evicted")
}
if _, exists := group.Members[survivor.ID]; !exists {
t.Fatalf("survivor should remain in group")
}
if group.Leader != survivor.ID {
t.Errorf("expected leader re-elected to %q, got %q", survivor.ID, group.Leader)
}
if group.State != GroupStatePreparingRebalance {
t.Errorf("expected state PreparingRebalance after eviction with survivors, got %s", group.State)
}
if group.Generation != 6 {
t.Errorf("expected generation 6, got %d", group.Generation)
}
if survivor.State != MemberStatePending {
t.Errorf("expected survivor state Pending, got %s", survivor.State)
}
if len(survivor.Assignment) != 0 {
t.Errorf("expected survivor assignment cleared so non-leader SyncGroup returns REBALANCE_IN_PROGRESS, got %v", survivor.Assignment)
}
if _, ok := group.SubscribedTopics["leader-only"]; ok {
t.Errorf("expected topic only the evicted member subscribed to be dropped from SubscribedTopics")
}
if _, ok := group.SubscribedTopics["shared-topic"]; !ok {
t.Errorf("expected shared-topic to remain in SubscribedTopics")
}
}
// TestGroupCoordinator_EvictExpiredMembersLocked_ZeroSessionTimeout makes sure
// a brand-new member that hasn't yet had its SessionTimeout populated isn't
// auto-evicted (zero is not "expired immediately").
func TestGroupCoordinator_EvictExpiredMembersLocked_ZeroSessionTimeout(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
pristine := &GroupMember{
ID: "pristine",
SessionTimeout: 0, // not yet populated
LastHeartbeat: time.Time{},
State: MemberStatePending,
}
group.Mu.Lock()
group.Members[pristine.ID] = pristine
evicted := gc.EvictExpiredMembersLocked(group)
defer group.Mu.Unlock()
if len(evicted) != 0 {
t.Errorf("member with zero SessionTimeout should not be evicted, got %v", evicted)
}
if _, exists := group.Members[pristine.ID]; !exists {
t.Errorf("pristine member should remain in group")
}
}

View File

@@ -0,0 +1,356 @@
package consumer
import (
"fmt"
"sort"
"time"
)
// RebalancePhase represents the phase of incremental cooperative rebalancing
type RebalancePhase int
const (
RebalancePhaseNone RebalancePhase = iota
RebalancePhaseRevocation
RebalancePhaseAssignment
)
func (rp RebalancePhase) String() string {
switch rp {
case RebalancePhaseNone:
return "None"
case RebalancePhaseRevocation:
return "Revocation"
case RebalancePhaseAssignment:
return "Assignment"
default:
return "Unknown"
}
}
// IncrementalRebalanceState tracks the state of incremental cooperative rebalancing
type IncrementalRebalanceState struct {
Phase RebalancePhase
RevocationGeneration int32 // Generation when revocation started
AssignmentGeneration int32 // Generation when assignment started
RevokedPartitions map[string][]PartitionAssignment // Member ID -> revoked partitions
PendingAssignments map[string][]PartitionAssignment // Member ID -> pending assignments
StartTime time.Time
RevocationTimeout time.Duration
}
// NewIncrementalRebalanceState creates a new incremental rebalance state
func NewIncrementalRebalanceState() *IncrementalRebalanceState {
return &IncrementalRebalanceState{
Phase: RebalancePhaseNone,
RevokedPartitions: make(map[string][]PartitionAssignment),
PendingAssignments: make(map[string][]PartitionAssignment),
RevocationTimeout: 30 * time.Second, // Default revocation timeout
}
}
// IncrementalCooperativeAssignmentStrategy implements incremental cooperative rebalancing
// This strategy performs rebalancing in two phases:
// 1. Revocation phase: Members give up partitions that need to be reassigned
// 2. Assignment phase: Members receive new partitions
type IncrementalCooperativeAssignmentStrategy struct {
rebalanceState *IncrementalRebalanceState
}
func NewIncrementalCooperativeAssignmentStrategy() *IncrementalCooperativeAssignmentStrategy {
return &IncrementalCooperativeAssignmentStrategy{
rebalanceState: NewIncrementalRebalanceState(),
}
}
func (ics *IncrementalCooperativeAssignmentStrategy) Name() string {
return ProtocolNameCooperativeSticky
}
func (ics *IncrementalCooperativeAssignmentStrategy) Assign(
members []*GroupMember,
topicPartitions map[string][]int32,
) map[string][]PartitionAssignment {
if len(members) == 0 {
return make(map[string][]PartitionAssignment)
}
// Check if we need to start a new rebalance
if ics.rebalanceState.Phase == RebalancePhaseNone {
return ics.startIncrementalRebalance(members, topicPartitions)
}
// Continue existing rebalance based on current phase
switch ics.rebalanceState.Phase {
case RebalancePhaseRevocation:
return ics.handleRevocationPhase(members, topicPartitions)
case RebalancePhaseAssignment:
return ics.handleAssignmentPhase(members, topicPartitions)
default:
// Fallback to regular assignment
return ics.performRegularAssignment(members, topicPartitions)
}
}
// startIncrementalRebalance initiates a new incremental rebalance
func (ics *IncrementalCooperativeAssignmentStrategy) startIncrementalRebalance(
members []*GroupMember,
topicPartitions map[string][]int32,
) map[string][]PartitionAssignment {
// Calculate ideal assignment
idealAssignment := ics.calculateIdealAssignment(members, topicPartitions)
// Determine which partitions need to be revoked
partitionsToRevoke := ics.calculateRevocations(members, idealAssignment)
if len(partitionsToRevoke) == 0 {
// No revocations needed, proceed with regular assignment
return idealAssignment
}
// Start revocation phase
ics.rebalanceState.Phase = RebalancePhaseRevocation
ics.rebalanceState.StartTime = time.Now()
ics.rebalanceState.RevokedPartitions = partitionsToRevoke
// Return current assignments minus revoked partitions
return ics.applyRevocations(members, partitionsToRevoke)
}
// handleRevocationPhase manages the revocation phase of incremental rebalancing
func (ics *IncrementalCooperativeAssignmentStrategy) handleRevocationPhase(
members []*GroupMember,
topicPartitions map[string][]int32,
) map[string][]PartitionAssignment {
// Check if revocation timeout has passed
if time.Since(ics.rebalanceState.StartTime) > ics.rebalanceState.RevocationTimeout {
// Force move to assignment phase
ics.rebalanceState.Phase = RebalancePhaseAssignment
return ics.handleAssignmentPhase(members, topicPartitions)
}
// Continue with revoked assignments (members should stop consuming revoked partitions)
return ics.getCurrentAssignmentsWithRevocations(members)
}
// handleAssignmentPhase manages the assignment phase of incremental rebalancing
func (ics *IncrementalCooperativeAssignmentStrategy) handleAssignmentPhase(
members []*GroupMember,
topicPartitions map[string][]int32,
) map[string][]PartitionAssignment {
// Calculate final assignment including previously revoked partitions
finalAssignment := ics.calculateIdealAssignment(members, topicPartitions)
// Complete the rebalance
ics.rebalanceState.Phase = RebalancePhaseNone
ics.rebalanceState.RevokedPartitions = make(map[string][]PartitionAssignment)
ics.rebalanceState.PendingAssignments = make(map[string][]PartitionAssignment)
return finalAssignment
}
// calculateIdealAssignment computes the ideal partition assignment
func (ics *IncrementalCooperativeAssignmentStrategy) calculateIdealAssignment(
members []*GroupMember,
topicPartitions map[string][]int32,
) map[string][]PartitionAssignment {
assignments := make(map[string][]PartitionAssignment)
for _, member := range members {
assignments[member.ID] = make([]PartitionAssignment, 0)
}
// Sort members for consistent assignment
sortedMembers := make([]*GroupMember, len(members))
copy(sortedMembers, members)
sort.Slice(sortedMembers, func(i, j int) bool {
return sortedMembers[i].ID < sortedMembers[j].ID
})
// Get all subscribed topics
subscribedTopics := make(map[string]bool)
for _, member := range members {
for _, topic := range member.Subscription {
subscribedTopics[topic] = true
}
}
// Collect all partitions that need assignment
allPartitions := make([]PartitionAssignment, 0)
for topic := range subscribedTopics {
partitions, exists := topicPartitions[topic]
if !exists {
continue
}
for _, partition := range partitions {
allPartitions = append(allPartitions, PartitionAssignment{
Topic: topic,
Partition: partition,
})
}
}
// Sort partitions for consistent assignment
sort.Slice(allPartitions, func(i, j int) bool {
if allPartitions[i].Topic != allPartitions[j].Topic {
return allPartitions[i].Topic < allPartitions[j].Topic
}
return allPartitions[i].Partition < allPartitions[j].Partition
})
// Distribute partitions based on subscriptions
if len(allPartitions) > 0 && len(sortedMembers) > 0 {
// Group partitions by topic
partitionsByTopic := make(map[string][]PartitionAssignment)
for _, partition := range allPartitions {
partitionsByTopic[partition.Topic] = append(partitionsByTopic[partition.Topic], partition)
}
// Assign partitions topic by topic
for topic, topicPartitions := range partitionsByTopic {
// Find members subscribed to this topic
subscribedMembers := make([]*GroupMember, 0)
for _, member := range sortedMembers {
for _, subscribedTopic := range member.Subscription {
if subscribedTopic == topic {
subscribedMembers = append(subscribedMembers, member)
break
}
}
}
if len(subscribedMembers) == 0 {
continue // No members subscribed to this topic
}
// Distribute topic partitions among subscribed members
partitionsPerMember := len(topicPartitions) / len(subscribedMembers)
extraPartitions := len(topicPartitions) % len(subscribedMembers)
partitionIndex := 0
for i, member := range subscribedMembers {
// Calculate how many partitions this member should get for this topic
numPartitions := partitionsPerMember
if i < extraPartitions {
numPartitions++
}
// Assign partitions to this member
for j := 0; j < numPartitions && partitionIndex < len(topicPartitions); j++ {
assignments[member.ID] = append(assignments[member.ID], topicPartitions[partitionIndex])
partitionIndex++
}
}
}
}
return assignments
}
// calculateRevocations determines which partitions need to be revoked for rebalancing
func (ics *IncrementalCooperativeAssignmentStrategy) calculateRevocations(
members []*GroupMember,
idealAssignment map[string][]PartitionAssignment,
) map[string][]PartitionAssignment {
revocations := make(map[string][]PartitionAssignment)
for _, member := range members {
currentAssignment := member.Assignment
memberIdealAssignment := idealAssignment[member.ID]
// Find partitions that are currently assigned but not in ideal assignment
currentMap := make(map[string]bool)
for _, assignment := range currentAssignment {
key := fmt.Sprintf("%s:%d", assignment.Topic, assignment.Partition)
currentMap[key] = true
}
idealMap := make(map[string]bool)
for _, assignment := range memberIdealAssignment {
key := fmt.Sprintf("%s:%d", assignment.Topic, assignment.Partition)
idealMap[key] = true
}
// Identify partitions to revoke
var toRevoke []PartitionAssignment
for _, assignment := range currentAssignment {
key := fmt.Sprintf("%s:%d", assignment.Topic, assignment.Partition)
if !idealMap[key] {
toRevoke = append(toRevoke, assignment)
}
}
if len(toRevoke) > 0 {
revocations[member.ID] = toRevoke
}
}
return revocations
}
// applyRevocations returns current assignments with specified partitions revoked
func (ics *IncrementalCooperativeAssignmentStrategy) applyRevocations(
members []*GroupMember,
revocations map[string][]PartitionAssignment,
) map[string][]PartitionAssignment {
assignments := make(map[string][]PartitionAssignment)
for _, member := range members {
assignments[member.ID] = make([]PartitionAssignment, 0)
// Get revoked partitions for this member
revokedPartitions := make(map[string]bool)
if revoked, exists := revocations[member.ID]; exists {
for _, partition := range revoked {
key := fmt.Sprintf("%s:%d", partition.Topic, partition.Partition)
revokedPartitions[key] = true
}
}
// Add current assignments except revoked ones
for _, assignment := range member.Assignment {
key := fmt.Sprintf("%s:%d", assignment.Topic, assignment.Partition)
if !revokedPartitions[key] {
assignments[member.ID] = append(assignments[member.ID], assignment)
}
}
}
return assignments
}
// getCurrentAssignmentsWithRevocations returns current assignments with revocations applied
func (ics *IncrementalCooperativeAssignmentStrategy) getCurrentAssignmentsWithRevocations(
members []*GroupMember,
) map[string][]PartitionAssignment {
return ics.applyRevocations(members, ics.rebalanceState.RevokedPartitions)
}
// performRegularAssignment performs a regular (non-incremental) assignment as fallback
func (ics *IncrementalCooperativeAssignmentStrategy) performRegularAssignment(
members []*GroupMember,
topicPartitions map[string][]int32,
) map[string][]PartitionAssignment {
// Reset rebalance state
ics.rebalanceState = NewIncrementalRebalanceState()
// Use ideal assignment calculation (non-incremental cooperative assignment)
return ics.calculateIdealAssignment(members, topicPartitions)
}
// GetRebalanceState returns the current rebalance state (for monitoring/debugging)
func (ics *IncrementalCooperativeAssignmentStrategy) GetRebalanceState() *IncrementalRebalanceState {
return ics.rebalanceState
}
// IsRebalanceInProgress returns true if an incremental rebalance is currently in progress
func (ics *IncrementalCooperativeAssignmentStrategy) IsRebalanceInProgress() bool {
return ics.rebalanceState.Phase != RebalancePhaseNone
}
// ForceCompleteRebalance forces completion of the current rebalance (for timeout scenarios)
func (ics *IncrementalCooperativeAssignmentStrategy) ForceCompleteRebalance() {
ics.rebalanceState.Phase = RebalancePhaseNone
ics.rebalanceState.RevokedPartitions = make(map[string][]PartitionAssignment)
ics.rebalanceState.PendingAssignments = make(map[string][]PartitionAssignment)
}

View File

@@ -0,0 +1,399 @@
package consumer
import (
"fmt"
"testing"
"time"
)
func TestIncrementalCooperativeAssignmentStrategy_BasicAssignment(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Create members
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{}, // No existing assignment
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{}, // No existing assignment
},
}
// Topic partitions
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2, 3},
}
// First assignment (no existing assignments, should be direct)
assignments := strategy.Assign(members, topicPartitions)
// Verify assignments
if len(assignments) != 2 {
t.Errorf("Expected 2 member assignments, got %d", len(assignments))
}
totalPartitions := 0
for memberID, partitions := range assignments {
t.Logf("Member %s assigned %d partitions: %v", memberID, len(partitions), partitions)
totalPartitions += len(partitions)
}
if totalPartitions != 4 {
t.Errorf("Expected 4 total partitions assigned, got %d", totalPartitions)
}
// Should not be in rebalance state for initial assignment
if strategy.IsRebalanceInProgress() {
t.Error("Expected no rebalance in progress for initial assignment")
}
}
func TestIncrementalCooperativeAssignmentStrategy_RebalanceWithRevocation(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Create members with existing assignments
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 0},
{Topic: "topic-1", Partition: 1},
{Topic: "topic-1", Partition: 2},
{Topic: "topic-1", Partition: 3}, // This member has all partitions
},
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{}, // New member with no assignments
},
}
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2, 3},
}
// First call should start revocation phase
assignments1 := strategy.Assign(members, topicPartitions)
// Should be in revocation phase
if !strategy.IsRebalanceInProgress() {
t.Error("Expected rebalance to be in progress")
}
state := strategy.GetRebalanceState()
if state.Phase != RebalancePhaseRevocation {
t.Errorf("Expected revocation phase, got %s", state.Phase)
}
// Member-1 should have some partitions revoked
member1Assignments := assignments1["member-1"]
if len(member1Assignments) >= 4 {
t.Errorf("Expected member-1 to have fewer than 4 partitions after revocation, got %d", len(member1Assignments))
}
// Member-2 should still have no assignments during revocation
member2Assignments := assignments1["member-2"]
if len(member2Assignments) != 0 {
t.Errorf("Expected member-2 to have 0 partitions during revocation, got %d", len(member2Assignments))
}
t.Logf("Revocation phase - Member-1: %d partitions, Member-2: %d partitions",
len(member1Assignments), len(member2Assignments))
// Simulate time passing and second call (should move to assignment phase)
time.Sleep(10 * time.Millisecond)
// Force move to assignment phase by setting timeout to 0
state.RevocationTimeout = 0
assignments2 := strategy.Assign(members, topicPartitions)
// Should complete rebalance
if strategy.IsRebalanceInProgress() {
t.Error("Expected rebalance to be completed")
}
// Both members should have partitions now
member1FinalAssignments := assignments2["member-1"]
member2FinalAssignments := assignments2["member-2"]
if len(member1FinalAssignments) == 0 {
t.Error("Expected member-1 to have some partitions after rebalance")
}
if len(member2FinalAssignments) == 0 {
t.Error("Expected member-2 to have some partitions after rebalance")
}
totalFinalPartitions := len(member1FinalAssignments) + len(member2FinalAssignments)
if totalFinalPartitions != 4 {
t.Errorf("Expected 4 total partitions after rebalance, got %d", totalFinalPartitions)
}
t.Logf("Final assignment - Member-1: %d partitions, Member-2: %d partitions",
len(member1FinalAssignments), len(member2FinalAssignments))
}
func TestIncrementalCooperativeAssignmentStrategy_NoRevocationNeeded(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Create members with already balanced assignments
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 0},
{Topic: "topic-1", Partition: 1},
},
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 2},
{Topic: "topic-1", Partition: 3},
},
},
}
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2, 3},
}
// Assignment should not trigger rebalance
assignments := strategy.Assign(members, topicPartitions)
// Should not be in rebalance state
if strategy.IsRebalanceInProgress() {
t.Error("Expected no rebalance in progress when assignments are already balanced")
}
// Assignments should remain the same
member1Assignments := assignments["member-1"]
member2Assignments := assignments["member-2"]
if len(member1Assignments) != 2 {
t.Errorf("Expected member-1 to keep 2 partitions, got %d", len(member1Assignments))
}
if len(member2Assignments) != 2 {
t.Errorf("Expected member-2 to keep 2 partitions, got %d", len(member2Assignments))
}
}
func TestIncrementalCooperativeAssignmentStrategy_MultipleTopics(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Create members with mixed topic subscriptions
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1", "topic-2"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 0},
{Topic: "topic-1", Partition: 1},
{Topic: "topic-2", Partition: 0},
},
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 2},
},
},
{
ID: "member-3",
Subscription: []string{"topic-2"},
Assignment: []PartitionAssignment{}, // New member
},
}
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2},
"topic-2": {0, 1},
}
// Should trigger rebalance to distribute topic-2 partitions
assignments := strategy.Assign(members, topicPartitions)
// Verify all partitions are assigned
allAssignedPartitions := make(map[string]bool)
for _, memberAssignments := range assignments {
for _, assignment := range memberAssignments {
key := fmt.Sprintf("%s:%d", assignment.Topic, assignment.Partition)
allAssignedPartitions[key] = true
}
}
expectedPartitions := []string{"topic-1:0", "topic-1:1", "topic-1:2", "topic-2:0", "topic-2:1"}
for _, expected := range expectedPartitions {
if !allAssignedPartitions[expected] {
t.Errorf("Expected partition %s to be assigned", expected)
}
}
// Debug: Print all assigned partitions
t.Logf("All assigned partitions: %v", allAssignedPartitions)
}
func TestIncrementalCooperativeAssignmentStrategy_ForceComplete(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Start a rebalance - create scenario where member-1 has all partitions but member-2 joins
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 0},
{Topic: "topic-1", Partition: 1},
{Topic: "topic-1", Partition: 2},
{Topic: "topic-1", Partition: 3},
},
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{}, // New member
},
}
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2, 3},
}
// This should start a rebalance (member-2 needs partitions)
strategy.Assign(members, topicPartitions)
if !strategy.IsRebalanceInProgress() {
t.Error("Expected rebalance to be in progress")
}
// Force complete the rebalance
strategy.ForceCompleteRebalance()
if strategy.IsRebalanceInProgress() {
t.Error("Expected rebalance to be completed after force complete")
}
state := strategy.GetRebalanceState()
if state.Phase != RebalancePhaseNone {
t.Errorf("Expected phase to be None after force complete, got %s", state.Phase)
}
}
func TestIncrementalCooperativeAssignmentStrategy_RevocationTimeout(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Set a very short revocation timeout for testing
strategy.rebalanceState.RevocationTimeout = 1 * time.Millisecond
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 0},
{Topic: "topic-1", Partition: 1},
{Topic: "topic-1", Partition: 2},
{Topic: "topic-1", Partition: 3},
},
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{},
},
}
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2, 3},
}
// First call starts revocation
strategy.Assign(members, topicPartitions)
if !strategy.IsRebalanceInProgress() {
t.Error("Expected rebalance to be in progress")
}
// Wait for timeout
time.Sleep(5 * time.Millisecond)
// Second call should complete due to timeout
assignments := strategy.Assign(members, topicPartitions)
if strategy.IsRebalanceInProgress() {
t.Error("Expected rebalance to be completed after timeout")
}
// Both members should have partitions
member1Assignments := assignments["member-1"]
member2Assignments := assignments["member-2"]
if len(member1Assignments) == 0 {
t.Error("Expected member-1 to have partitions after timeout")
}
if len(member2Assignments) == 0 {
t.Error("Expected member-2 to have partitions after timeout")
}
}
func TestIncrementalCooperativeAssignmentStrategy_StateTransitions(t *testing.T) {
strategy := NewIncrementalCooperativeAssignmentStrategy()
// Initial state should be None
state := strategy.GetRebalanceState()
if state.Phase != RebalancePhaseNone {
t.Errorf("Expected initial phase to be None, got %s", state.Phase)
}
// Create scenario that requires rebalancing
members := []*GroupMember{
{
ID: "member-1",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{
{Topic: "topic-1", Partition: 0},
{Topic: "topic-1", Partition: 1},
{Topic: "topic-1", Partition: 2},
{Topic: "topic-1", Partition: 3},
},
},
{
ID: "member-2",
Subscription: []string{"topic-1"},
Assignment: []PartitionAssignment{}, // New member
},
}
topicPartitions := map[string][]int32{
"topic-1": {0, 1, 2, 3}, // Same partitions, but need rebalancing due to new member
}
// First call should move to revocation phase
strategy.Assign(members, topicPartitions)
state = strategy.GetRebalanceState()
if state.Phase != RebalancePhaseRevocation {
t.Errorf("Expected phase to be Revocation, got %s", state.Phase)
}
// Force timeout to move to assignment phase
state.RevocationTimeout = 0
strategy.Assign(members, topicPartitions)
// Should complete and return to None
state = strategy.GetRebalanceState()
if state.Phase != RebalancePhaseNone {
t.Errorf("Expected phase to be None after completion, got %s", state.Phase)
}
}

View File

@@ -0,0 +1,216 @@
package consumer
import (
"time"
)
// RebalanceTimeoutManager handles rebalance timeout logic and member eviction
type RebalanceTimeoutManager struct {
coordinator *GroupCoordinator
}
// NewRebalanceTimeoutManager creates a new rebalance timeout manager
func NewRebalanceTimeoutManager(coordinator *GroupCoordinator) *RebalanceTimeoutManager {
return &RebalanceTimeoutManager{
coordinator: coordinator,
}
}
// CheckRebalanceTimeouts checks for members that have exceeded rebalance timeouts
func (rtm *RebalanceTimeoutManager) CheckRebalanceTimeouts() {
now := time.Now()
rtm.coordinator.groupsMu.RLock()
defer rtm.coordinator.groupsMu.RUnlock()
for _, group := range rtm.coordinator.groups {
group.Mu.Lock()
// Only check timeouts for groups in rebalancing states
if group.State == GroupStatePreparingRebalance || group.State == GroupStateCompletingRebalance {
rtm.checkGroupRebalanceTimeout(group, now)
}
group.Mu.Unlock()
}
}
// checkGroupRebalanceTimeout checks and handles rebalance timeout for a specific group
func (rtm *RebalanceTimeoutManager) checkGroupRebalanceTimeout(group *ConsumerGroup, now time.Time) {
expiredMembers := make([]string, 0)
for memberID, member := range group.Members {
// Check if member has exceeded its rebalance timeout
rebalanceTimeout := time.Duration(member.RebalanceTimeout) * time.Millisecond
if rebalanceTimeout == 0 {
// Use default rebalance timeout if not specified
rebalanceTimeout = time.Duration(rtm.coordinator.rebalanceTimeoutMs) * time.Millisecond
}
// For members in pending state during rebalance, check against join time
if member.State == MemberStatePending {
if now.Sub(member.JoinedAt) > rebalanceTimeout {
expiredMembers = append(expiredMembers, memberID)
}
}
// Also check session timeout as a fallback
sessionTimeout := time.Duration(member.SessionTimeout) * time.Millisecond
if now.Sub(member.LastHeartbeat) > sessionTimeout {
expiredMembers = append(expiredMembers, memberID)
}
}
// Remove expired members and trigger rebalance if necessary
if len(expiredMembers) > 0 {
rtm.evictExpiredMembers(group, expiredMembers)
}
}
// evictExpiredMembers removes expired members and updates group state
func (rtm *RebalanceTimeoutManager) evictExpiredMembers(group *ConsumerGroup, expiredMembers []string) {
for _, memberID := range expiredMembers {
delete(group.Members, memberID)
// If the leader was evicted, clear leader
if group.Leader == memberID {
group.Leader = ""
}
}
// Update group state based on remaining members
if len(group.Members) == 0 {
group.State = GroupStateEmpty
group.Generation++
group.Leader = ""
} else {
// If we were in the middle of rebalancing, restart the process
if group.State == GroupStatePreparingRebalance || group.State == GroupStateCompletingRebalance {
// Select new leader if needed
if group.Leader == "" {
for memberID := range group.Members {
group.Leader = memberID
break
}
}
// Reset to preparing rebalance to restart the process
group.State = GroupStatePreparingRebalance
group.Generation++
// Mark remaining members as pending
for _, member := range group.Members {
member.State = MemberStatePending
}
}
}
group.LastActivity = time.Now()
}
// IsRebalanceStuck checks if a group has been stuck in rebalancing for too long
func (rtm *RebalanceTimeoutManager) IsRebalanceStuck(group *ConsumerGroup, maxRebalanceDuration time.Duration) bool {
if group.State != GroupStatePreparingRebalance && group.State != GroupStateCompletingRebalance {
return false
}
return time.Since(group.LastActivity) > maxRebalanceDuration
}
// ForceCompleteRebalance forces completion of a stuck rebalance.
// IMPORTANT: The caller must already hold group.Mu.Lock().
func (rtm *RebalanceTimeoutManager) ForceCompleteRebalance(group *ConsumerGroup) {
// If stuck in preparing rebalance, move to completing
if group.State == GroupStatePreparingRebalance {
group.State = GroupStateCompletingRebalance
group.LastActivity = time.Now()
return
}
// If stuck in completing rebalance, force to stable
if group.State == GroupStateCompletingRebalance {
group.State = GroupStateStable
for _, member := range group.Members {
member.State = MemberStateStable
}
group.LastActivity = time.Now()
return
}
}
// GetRebalanceStatus returns the current rebalance status for a group
func (rtm *RebalanceTimeoutManager) GetRebalanceStatus(groupID string) *RebalanceStatus {
group := rtm.coordinator.GetGroup(groupID)
if group == nil {
return nil
}
group.Mu.RLock()
defer group.Mu.RUnlock()
status := &RebalanceStatus{
GroupID: groupID,
State: group.State,
Generation: group.Generation,
MemberCount: len(group.Members),
Leader: group.Leader,
LastActivity: group.LastActivity,
IsRebalancing: group.State == GroupStatePreparingRebalance || group.State == GroupStateCompletingRebalance,
RebalanceDuration: time.Since(group.LastActivity),
}
// Calculate member timeout status
now := time.Now()
for memberID, member := range group.Members {
memberStatus := MemberTimeoutStatus{
MemberID: memberID,
State: member.State,
LastHeartbeat: member.LastHeartbeat,
JoinedAt: member.JoinedAt,
SessionTimeout: time.Duration(member.SessionTimeout) * time.Millisecond,
RebalanceTimeout: time.Duration(member.RebalanceTimeout) * time.Millisecond,
}
// Calculate time until session timeout
sessionTimeRemaining := memberStatus.SessionTimeout - now.Sub(member.LastHeartbeat)
if sessionTimeRemaining < 0 {
sessionTimeRemaining = 0
}
memberStatus.SessionTimeRemaining = sessionTimeRemaining
// Calculate time until rebalance timeout
rebalanceTimeRemaining := memberStatus.RebalanceTimeout - now.Sub(member.JoinedAt)
if rebalanceTimeRemaining < 0 {
rebalanceTimeRemaining = 0
}
memberStatus.RebalanceTimeRemaining = rebalanceTimeRemaining
status.Members = append(status.Members, memberStatus)
}
return status
}
// RebalanceStatus represents the current status of a group's rebalance
type RebalanceStatus struct {
GroupID string `json:"group_id"`
State GroupState `json:"state"`
Generation int32 `json:"generation"`
MemberCount int `json:"member_count"`
Leader string `json:"leader"`
LastActivity time.Time `json:"last_activity"`
IsRebalancing bool `json:"is_rebalancing"`
RebalanceDuration time.Duration `json:"rebalance_duration"`
Members []MemberTimeoutStatus `json:"members"`
}
// MemberTimeoutStatus represents timeout status for a group member
type MemberTimeoutStatus struct {
MemberID string `json:"member_id"`
State MemberState `json:"state"`
LastHeartbeat time.Time `json:"last_heartbeat"`
JoinedAt time.Time `json:"joined_at"`
SessionTimeout time.Duration `json:"session_timeout"`
RebalanceTimeout time.Duration `json:"rebalance_timeout"`
SessionTimeRemaining time.Duration `json:"session_time_remaining"`
RebalanceTimeRemaining time.Duration `json:"rebalance_time_remaining"`
}

View File

@@ -0,0 +1,330 @@
package consumer
import (
"testing"
"time"
)
func TestRebalanceTimeoutManager_CheckRebalanceTimeouts(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Create a group with a member that has a short rebalance timeout
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
member := &GroupMember{
ID: "member1",
ClientID: "client1",
SessionTimeout: 30000, // 30 seconds
RebalanceTimeout: 1000, // 1 second (very short for testing)
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now().Add(-2 * time.Second), // Joined 2 seconds ago
}
group.Members["member1"] = member
group.Mu.Unlock()
// Check timeouts - member should be evicted
rtm.CheckRebalanceTimeouts()
group.Mu.RLock()
if len(group.Members) != 0 {
t.Errorf("Expected member to be evicted due to rebalance timeout, but %d members remain", len(group.Members))
}
if group.State != GroupStateEmpty {
t.Errorf("Expected group state to be Empty after member eviction, got %s", group.State.String())
}
group.Mu.RUnlock()
}
func TestRebalanceTimeoutManager_SessionTimeoutFallback(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Create a group with a member that has exceeded session timeout
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
member := &GroupMember{
ID: "member1",
ClientID: "client1",
SessionTimeout: 1000, // 1 second
RebalanceTimeout: 30000, // 30 seconds
State: MemberStatePending,
LastHeartbeat: time.Now().Add(-2 * time.Second), // Last heartbeat 2 seconds ago
JoinedAt: time.Now(),
}
group.Members["member1"] = member
group.Mu.Unlock()
// Check timeouts - member should be evicted due to session timeout
rtm.CheckRebalanceTimeouts()
group.Mu.RLock()
if len(group.Members) != 0 {
t.Errorf("Expected member to be evicted due to session timeout, but %d members remain", len(group.Members))
}
group.Mu.RUnlock()
}
func TestRebalanceTimeoutManager_LeaderEviction(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Create a group with leader and another member
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
group.Leader = "member1"
// Leader with expired rebalance timeout
leader := &GroupMember{
ID: "member1",
ClientID: "client1",
SessionTimeout: 30000,
RebalanceTimeout: 1000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now().Add(-2 * time.Second),
}
group.Members["member1"] = leader
// Another member that's still valid
member2 := &GroupMember{
ID: "member2",
ClientID: "client2",
SessionTimeout: 30000,
RebalanceTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
group.Members["member2"] = member2
group.Mu.Unlock()
// Check timeouts - leader should be evicted, new leader selected
rtm.CheckRebalanceTimeouts()
group.Mu.RLock()
if len(group.Members) != 1 {
t.Errorf("Expected 1 member to remain after leader eviction, got %d", len(group.Members))
}
if group.Leader != "member2" {
t.Errorf("Expected member2 to become new leader, got %s", group.Leader)
}
if group.State != GroupStatePreparingRebalance {
t.Errorf("Expected group to restart rebalancing after leader eviction, got %s", group.State.String())
}
group.Mu.RUnlock()
}
func TestRebalanceTimeoutManager_IsRebalanceStuck(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Create a group that's been rebalancing for a while
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
group.LastActivity = time.Now().Add(-15 * time.Minute) // 15 minutes ago
group.Mu.Unlock()
// Check if rebalance is stuck (max 10 minutes)
maxDuration := 10 * time.Minute
if !rtm.IsRebalanceStuck(group, maxDuration) {
t.Error("Expected rebalance to be detected as stuck")
}
// Test with a group that's not stuck
group.Mu.Lock()
group.LastActivity = time.Now().Add(-5 * time.Minute) // 5 minutes ago
group.Mu.Unlock()
if rtm.IsRebalanceStuck(group, maxDuration) {
t.Error("Expected rebalance to not be detected as stuck")
}
// Test with stable group (should not be stuck)
group.Mu.Lock()
group.State = GroupStateStable
group.LastActivity = time.Now().Add(-15 * time.Minute)
group.Mu.Unlock()
if rtm.IsRebalanceStuck(group, maxDuration) {
t.Error("Stable group should not be detected as stuck")
}
}
func TestRebalanceTimeoutManager_ForceCompleteRebalance(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Test forcing completion from PreparingRebalance
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
member := &GroupMember{
ID: "member1",
State: MemberStatePending,
}
group.Members["member1"] = member
// ForceCompleteRebalance expects the caller to hold group.Mu.Lock()
rtm.ForceCompleteRebalance(group)
if group.State != GroupStateCompletingRebalance {
t.Errorf("Expected group state to be CompletingRebalance, got %s", group.State.String())
}
group.Mu.Unlock()
// Test forcing completion from CompletingRebalance
group.Mu.Lock()
rtm.ForceCompleteRebalance(group)
if group.State != GroupStateStable {
t.Errorf("Expected group state to be Stable, got %s", group.State.String())
}
if member.State != MemberStateStable {
t.Errorf("Expected member state to be Stable, got %s", member.State.String())
}
group.Mu.Unlock()
}
func TestRebalanceTimeoutManager_GetRebalanceStatus(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Test with non-existent group
status := rtm.GetRebalanceStatus("non-existent")
if status != nil {
t.Error("Expected nil status for non-existent group")
}
// Create a group with members
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
group.Generation = 5
group.Leader = "member1"
group.LastActivity = time.Now().Add(-2 * time.Minute)
member1 := &GroupMember{
ID: "member1",
State: MemberStatePending,
LastHeartbeat: time.Now().Add(-30 * time.Second),
JoinedAt: time.Now().Add(-2 * time.Minute),
SessionTimeout: 30000, // 30 seconds
RebalanceTimeout: 300000, // 5 minutes
}
group.Members["member1"] = member1
member2 := &GroupMember{
ID: "member2",
State: MemberStatePending,
LastHeartbeat: time.Now().Add(-10 * time.Second),
JoinedAt: time.Now().Add(-1 * time.Minute),
SessionTimeout: 60000, // 1 minute
RebalanceTimeout: 180000, // 3 minutes
}
group.Members["member2"] = member2
group.Mu.Unlock()
// Get status
status = rtm.GetRebalanceStatus("test-group")
if status == nil {
t.Fatal("Expected non-nil status")
}
if status.GroupID != "test-group" {
t.Errorf("Expected group ID 'test-group', got %s", status.GroupID)
}
if status.State != GroupStatePreparingRebalance {
t.Errorf("Expected state PreparingRebalance, got %s", status.State.String())
}
if status.Generation != 5 {
t.Errorf("Expected generation 5, got %d", status.Generation)
}
if status.MemberCount != 2 {
t.Errorf("Expected 2 members, got %d", status.MemberCount)
}
if status.Leader != "member1" {
t.Errorf("Expected leader 'member1', got %s", status.Leader)
}
if !status.IsRebalancing {
t.Error("Expected IsRebalancing to be true")
}
if len(status.Members) != 2 {
t.Errorf("Expected 2 member statuses, got %d", len(status.Members))
}
// Check member timeout calculations
for _, memberStatus := range status.Members {
if memberStatus.SessionTimeRemaining < 0 {
t.Errorf("Session time remaining should not be negative for member %s", memberStatus.MemberID)
}
if memberStatus.RebalanceTimeRemaining < 0 {
t.Errorf("Rebalance time remaining should not be negative for member %s", memberStatus.MemberID)
}
}
}
func TestRebalanceTimeoutManager_DefaultRebalanceTimeout(t *testing.T) {
coordinator := NewGroupCoordinator()
defer coordinator.Close()
rtm := coordinator.rebalanceTimeoutManager
// Create a group with a member that has no rebalance timeout set (0)
group := coordinator.GetOrCreateGroup("test-group")
group.Mu.Lock()
group.State = GroupStatePreparingRebalance
member := &GroupMember{
ID: "member1",
ClientID: "client1",
SessionTimeout: 30000, // 30 seconds
RebalanceTimeout: 0, // Not set, should use default
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now().Add(-6 * time.Minute), // Joined 6 minutes ago
}
group.Members["member1"] = member
group.Mu.Unlock()
// Default rebalance timeout is 5 minutes (300000ms), so member should be evicted
rtm.CheckRebalanceTimeouts()
group.Mu.RLock()
if len(group.Members) != 0 {
t.Errorf("Expected member to be evicted using default rebalance timeout, but %d members remain", len(group.Members))
}
group.Mu.RUnlock()
}

View File

@@ -0,0 +1,196 @@
package consumer
import (
"testing"
"time"
)
func TestGroupCoordinator_StaticMembership(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
// Test static member registration
instanceID := "static-instance-1"
member := &GroupMember{
ID: "member-1",
ClientID: "client-1",
ClientHost: "localhost",
GroupInstanceID: &instanceID,
SessionTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
// Add member to group
group.Members[member.ID] = member
gc.RegisterStaticMember(group, member)
// Test finding static member
foundMember := gc.FindStaticMember(group, instanceID)
if foundMember == nil {
t.Error("Expected to find static member, got nil")
}
if foundMember.ID != member.ID {
t.Errorf("Expected member ID %s, got %s", member.ID, foundMember.ID)
}
// Test IsStaticMember
if !gc.IsStaticMember(member) {
t.Error("Expected member to be static")
}
// Test dynamic member (no instance ID)
dynamicMember := &GroupMember{
ID: "member-2",
ClientID: "client-2",
ClientHost: "localhost",
GroupInstanceID: nil,
SessionTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
if gc.IsStaticMember(dynamicMember) {
t.Error("Expected member to be dynamic")
}
// Test unregistering static member
gc.UnregisterStaticMember(group, instanceID)
foundMember = gc.FindStaticMember(group, instanceID)
if foundMember != nil {
t.Error("Expected static member to be unregistered")
}
}
func TestGroupCoordinator_StaticMemberReconnection(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
instanceID := "static-instance-1"
// First connection
member1 := &GroupMember{
ID: "member-1",
ClientID: "client-1",
ClientHost: "localhost",
GroupInstanceID: &instanceID,
SessionTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
group.Members[member1.ID] = member1
gc.RegisterStaticMember(group, member1)
// Simulate disconnection and reconnection with same instance ID
delete(group.Members, member1.ID)
// Reconnection with same instance ID should reuse the mapping
member2 := &GroupMember{
ID: "member-2", // Different member ID
ClientID: "client-1",
ClientHost: "localhost",
GroupInstanceID: &instanceID, // Same instance ID
SessionTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
group.Members[member2.ID] = member2
gc.RegisterStaticMember(group, member2)
// Should find the new member with the same instance ID
foundMember := gc.FindStaticMember(group, instanceID)
if foundMember == nil {
t.Error("Expected to find static member after reconnection")
}
if foundMember.ID != member2.ID {
t.Errorf("Expected member ID %s, got %s", member2.ID, foundMember.ID)
}
}
func TestGroupCoordinator_StaticMembershipEdgeCases(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
// Test empty instance ID
member := &GroupMember{
ID: "member-1",
ClientID: "client-1",
ClientHost: "localhost",
GroupInstanceID: nil,
SessionTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
gc.RegisterStaticMember(group, member) // Should be no-op
foundMember := gc.FindStaticMember(group, "")
if foundMember != nil {
t.Error("Expected not to find member with empty instance ID")
}
// Test empty string instance ID
emptyInstanceID := ""
member.GroupInstanceID = &emptyInstanceID
gc.RegisterStaticMember(group, member) // Should be no-op
foundMember = gc.FindStaticMember(group, emptyInstanceID)
if foundMember != nil {
t.Error("Expected not to find member with empty string instance ID")
}
// Test unregistering non-existent instance ID
gc.UnregisterStaticMember(group, "non-existent") // Should be no-op
}
func TestGroupCoordinator_StaticMembershipConcurrency(t *testing.T) {
gc := NewGroupCoordinator()
defer gc.Close()
group := gc.GetOrCreateGroup("test-group")
instanceID := "static-instance-1"
// Test concurrent access
done := make(chan bool, 2)
// Goroutine 1: Register static member
go func() {
member := &GroupMember{
ID: "member-1",
ClientID: "client-1",
ClientHost: "localhost",
GroupInstanceID: &instanceID,
SessionTimeout: 30000,
State: MemberStatePending,
LastHeartbeat: time.Now(),
JoinedAt: time.Now(),
}
group.Members[member.ID] = member
gc.RegisterStaticMember(group, member)
done <- true
}()
// Goroutine 2: Find static member
go func() {
time.Sleep(10 * time.Millisecond) // Small delay to ensure registration happens first
foundMember := gc.FindStaticMember(group, instanceID)
if foundMember == nil {
t.Error("Expected to find static member in concurrent access")
}
done <- true
}()
// Wait for both goroutines to complete
<-done
<-done
}