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
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:
299
weed/mq/kafka/consumer/assignment.go
Normal file
299
weed/mq/kafka/consumer/assignment.go
Normal 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
|
||||
}
|
||||
359
weed/mq/kafka/consumer/assignment_test.go
Normal file
359
weed/mq/kafka/consumer/assignment_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
423
weed/mq/kafka/consumer/cooperative_sticky_test.go
Normal file
423
weed/mq/kafka/consumer/cooperative_sticky_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
447
weed/mq/kafka/consumer/group_coordinator.go
Normal file
447
weed/mq/kafka/consumer/group_coordinator.go
Normal 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)
|
||||
}
|
||||
397
weed/mq/kafka/consumer/group_coordinator_test.go
Normal file
397
weed/mq/kafka/consumer/group_coordinator_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
356
weed/mq/kafka/consumer/incremental_rebalancing.go
Normal file
356
weed/mq/kafka/consumer/incremental_rebalancing.go
Normal 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)
|
||||
}
|
||||
399
weed/mq/kafka/consumer/incremental_rebalancing_test.go
Normal file
399
weed/mq/kafka/consumer/incremental_rebalancing_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
216
weed/mq/kafka/consumer/rebalance_timeout.go
Normal file
216
weed/mq/kafka/consumer/rebalance_timeout.go
Normal 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"`
|
||||
}
|
||||
330
weed/mq/kafka/consumer/rebalance_timeout_test.go
Normal file
330
weed/mq/kafka/consumer/rebalance_timeout_test.go
Normal 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()
|
||||
}
|
||||
196
weed/mq/kafka/consumer/static_membership_test.go
Normal file
196
weed/mq/kafka/consumer/static_membership_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user