github.com/rbisecke/kafka-go@v0.4.27/groupbalancer_test.go (about)

     1  package kafka
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"reflect"
     7  	"strconv"
     8  	"testing"
     9  )
    10  
    11  func TestFindMembersByTopic(t *testing.T) {
    12  	a1 := GroupMember{
    13  		ID:     "a",
    14  		Topics: []string{"topic-1"},
    15  	}
    16  	a12 := GroupMember{
    17  		ID:     "a",
    18  		Topics: []string{"topic-1", "topic-2"},
    19  	}
    20  	b23 := GroupMember{
    21  		ID:     "b",
    22  		Topics: []string{"topic-2", "topic-3"},
    23  	}
    24  
    25  	tests := map[string]struct {
    26  		Members  []GroupMember
    27  		Expected map[string][]GroupMember
    28  	}{
    29  		"empty": {
    30  			Expected: map[string][]GroupMember{},
    31  		},
    32  		"one member, one topic": {
    33  			Members: []GroupMember{a1},
    34  			Expected: map[string][]GroupMember{
    35  				"topic-1": {
    36  					a1,
    37  				},
    38  			},
    39  		},
    40  		"one member, multiple topics": {
    41  			Members: []GroupMember{a12},
    42  			Expected: map[string][]GroupMember{
    43  				"topic-1": {
    44  					a12,
    45  				},
    46  				"topic-2": {
    47  					a12,
    48  				},
    49  			},
    50  		},
    51  		"multiple members, multiple topics": {
    52  			Members: []GroupMember{a12, b23},
    53  			Expected: map[string][]GroupMember{
    54  				"topic-1": {
    55  					a12,
    56  				},
    57  				"topic-2": {
    58  					a12,
    59  					b23,
    60  				},
    61  				"topic-3": {
    62  					b23,
    63  				},
    64  			},
    65  		},
    66  	}
    67  
    68  	for label, test := range tests {
    69  		t.Run(label, func(t *testing.T) {
    70  			membersByTopic := findMembersByTopic(test.Members)
    71  			if !reflect.DeepEqual(test.Expected, membersByTopic) {
    72  				t.Errorf("expected %#v; got %#v", test.Expected, membersByTopic)
    73  			}
    74  		})
    75  	}
    76  }
    77  
    78  func TestRangeAssignGroups(t *testing.T) {
    79  	newMeta := func(memberID string, topics ...string) GroupMember {
    80  		return GroupMember{
    81  			ID:     memberID,
    82  			Topics: topics,
    83  		}
    84  	}
    85  
    86  	newPartitions := func(partitionCount int, topics ...string) []Partition {
    87  		partitions := make([]Partition, 0, len(topics)*partitionCount)
    88  		for _, topic := range topics {
    89  			for partition := 0; partition < partitionCount; partition++ {
    90  				partitions = append(partitions, Partition{
    91  					Topic: topic,
    92  					ID:    partition,
    93  				})
    94  			}
    95  		}
    96  		return partitions
    97  	}
    98  
    99  	tests := map[string]struct {
   100  		Members    []GroupMember
   101  		Partitions []Partition
   102  		Expected   GroupMemberAssignments
   103  	}{
   104  		"empty": {
   105  			Expected: GroupMemberAssignments{},
   106  		},
   107  		"one member, one topic, one partition": {
   108  			Members: []GroupMember{
   109  				newMeta("a", "topic-1"),
   110  			},
   111  			Partitions: newPartitions(1, "topic-1"),
   112  			Expected: GroupMemberAssignments{
   113  				"a": map[string][]int{
   114  					"topic-1": {0},
   115  				},
   116  			},
   117  		},
   118  		"one member, one topic, multiple partitions": {
   119  			Members: []GroupMember{
   120  				newMeta("a", "topic-1"),
   121  			},
   122  			Partitions: newPartitions(3, "topic-1"),
   123  			Expected: GroupMemberAssignments{
   124  				"a": map[string][]int{
   125  					"topic-1": {0, 1, 2},
   126  				},
   127  			},
   128  		},
   129  		"multiple members, one topic, one partition": {
   130  			Members: []GroupMember{
   131  				newMeta("a", "topic-1"),
   132  				newMeta("b", "topic-1"),
   133  			},
   134  			Partitions: newPartitions(1, "topic-1"),
   135  			Expected: GroupMemberAssignments{
   136  				"a": map[string][]int{},
   137  				"b": map[string][]int{
   138  					"topic-1": {0},
   139  				},
   140  			},
   141  		},
   142  		"multiple members, one topic, multiple partitions": {
   143  			Members: []GroupMember{
   144  				newMeta("a", "topic-1"),
   145  				newMeta("b", "topic-1"),
   146  			},
   147  			Partitions: newPartitions(3, "topic-1"),
   148  			Expected: GroupMemberAssignments{
   149  				"a": map[string][]int{
   150  					"topic-1": {0},
   151  				},
   152  				"b": map[string][]int{
   153  					"topic-1": {1, 2},
   154  				},
   155  			},
   156  		},
   157  		"multiple members, multiple topics, multiple partitions": {
   158  			Members: []GroupMember{
   159  				newMeta("a", "topic-1", "topic-2"),
   160  				newMeta("b", "topic-2", "topic-3"),
   161  			},
   162  			Partitions: newPartitions(3, "topic-1", "topic-2", "topic-3"),
   163  			Expected: GroupMemberAssignments{
   164  				"a": map[string][]int{
   165  					"topic-1": {0, 1, 2},
   166  					"topic-2": {0},
   167  				},
   168  				"b": map[string][]int{
   169  					"topic-2": {1, 2},
   170  					"topic-3": {0, 1, 2},
   171  				},
   172  			},
   173  		},
   174  	}
   175  
   176  	for label, test := range tests {
   177  		t.Run(label, func(t *testing.T) {
   178  			assignments := RangeGroupBalancer{}.AssignGroups(test.Members, test.Partitions)
   179  			if !reflect.DeepEqual(test.Expected, assignments) {
   180  				buf := bytes.NewBuffer(nil)
   181  				encoder := json.NewEncoder(buf)
   182  				encoder.SetIndent("", "  ")
   183  
   184  				buf.WriteString("expected: ")
   185  				encoder.Encode(test.Expected)
   186  				buf.WriteString("got: ")
   187  				encoder.Encode(assignments)
   188  
   189  				t.Error(buf.String())
   190  			}
   191  		})
   192  	}
   193  }
   194  
   195  // For 66 members, 213 partitions, each member should get 213/66 = 3.22 partitions.
   196  // This means that in practice, each member should get either 3 or 4 partitions
   197  // assigned to it. Any other number is a failure.
   198  func TestRangeAssignGroupsUnbalanced(t *testing.T) {
   199  	members := []GroupMember{}
   200  	for i := 0; i < 66; i++ {
   201  		members = append(members, GroupMember{
   202  			ID:     strconv.Itoa(i),
   203  			Topics: []string{"topic-1"},
   204  		})
   205  	}
   206  	partitions := []Partition{}
   207  	for i := 0; i < 213; i++ {
   208  		partitions = append(partitions, Partition{
   209  			ID:    i,
   210  			Topic: "topic-1",
   211  		})
   212  	}
   213  
   214  	assignments := RangeGroupBalancer{}.AssignGroups(members, partitions)
   215  	if len(assignments) != len(members) {
   216  		t.Fatalf("Assignment count mismatch: %d != %d", len(assignments), len(members))
   217  	}
   218  
   219  	for _, m := range assignments {
   220  		if len(m["topic-1"]) < 3 || len(m["topic-1"]) > 4 {
   221  			t.Fatalf("Expected assignment of 3 or 4 partitions, got %d", len(m["topic-1"]))
   222  		}
   223  	}
   224  }
   225  
   226  func TestRoundRobinAssignGroups(t *testing.T) {
   227  	newPartitions := func(partitionCount int, topics ...string) []Partition {
   228  		partitions := make([]Partition, 0, len(topics)*partitionCount)
   229  		for _, topic := range topics {
   230  			for partition := 0; partition < partitionCount; partition++ {
   231  				partitions = append(partitions, Partition{
   232  					Topic: topic,
   233  					ID:    partition,
   234  				})
   235  			}
   236  		}
   237  		return partitions
   238  	}
   239  
   240  	tests := map[string]struct {
   241  		Members    []GroupMember
   242  		Partitions []Partition
   243  		Expected   GroupMemberAssignments
   244  	}{
   245  		"empty": {
   246  			Expected: GroupMemberAssignments{},
   247  		},
   248  		"one member, one topic, one partition": {
   249  			Members: []GroupMember{
   250  				{
   251  					ID:     "a",
   252  					Topics: []string{"topic-1"},
   253  				},
   254  			},
   255  			Partitions: newPartitions(1, "topic-1"),
   256  			Expected: GroupMemberAssignments{
   257  				"a": map[string][]int{
   258  					"topic-1": {0},
   259  				},
   260  			},
   261  		},
   262  		"one member, one topic, multiple partitions": {
   263  			Members: []GroupMember{
   264  				{
   265  					ID:     "a",
   266  					Topics: []string{"topic-1"},
   267  				},
   268  			},
   269  			Partitions: newPartitions(3, "topic-1"),
   270  			Expected: GroupMemberAssignments{
   271  				"a": map[string][]int{
   272  					"topic-1": {0, 1, 2},
   273  				},
   274  			},
   275  		},
   276  		"multiple members, one topic, one partition": {
   277  			Members: []GroupMember{
   278  				{
   279  					ID:     "a",
   280  					Topics: []string{"topic-1"},
   281  				},
   282  				{
   283  					ID:     "b",
   284  					Topics: []string{"topic-1"},
   285  				},
   286  			},
   287  			Partitions: newPartitions(1, "topic-1"),
   288  			Expected: GroupMemberAssignments{
   289  				"a": map[string][]int{
   290  					"topic-1": {0},
   291  				},
   292  				"b": map[string][]int{},
   293  			},
   294  		},
   295  		"multiple members, multiple topics, multiple partitions": {
   296  			Members: []GroupMember{
   297  				{
   298  					ID:     "a",
   299  					Topics: []string{"topic-1", "topic-2"},
   300  				},
   301  				{
   302  					ID:     "b",
   303  					Topics: []string{"topic-2", "topic-3"},
   304  				},
   305  			},
   306  			Partitions: newPartitions(3, "topic-1", "topic-2", "topic-3"),
   307  			Expected: GroupMemberAssignments{
   308  				"a": map[string][]int{
   309  					"topic-1": {0, 1, 2},
   310  					"topic-2": {0, 2},
   311  				},
   312  				"b": map[string][]int{
   313  					"topic-2": {1},
   314  					"topic-3": {0, 1, 2},
   315  				},
   316  			},
   317  		},
   318  	}
   319  
   320  	for label, test := range tests {
   321  		t.Run(label, func(t *testing.T) {
   322  			assignments := RoundRobinGroupBalancer{}.AssignGroups(test.Members, test.Partitions)
   323  			if !reflect.DeepEqual(test.Expected, assignments) {
   324  				buf := bytes.NewBuffer(nil)
   325  				encoder := json.NewEncoder(buf)
   326  				encoder.SetIndent("", "  ")
   327  
   328  				buf.WriteString("expected: ")
   329  				encoder.Encode(test.Expected)
   330  				buf.WriteString("got: ")
   331  				encoder.Encode(assignments)
   332  
   333  				t.Error(buf.String())
   334  			}
   335  		})
   336  	}
   337  }
   338  
   339  func TestFindMembersByTopicSortsByMemberID(t *testing.T) {
   340  	topic := "topic-1"
   341  	a := GroupMember{
   342  		ID:     "a",
   343  		Topics: []string{topic},
   344  	}
   345  	b := GroupMember{
   346  		ID:     "b",
   347  		Topics: []string{topic},
   348  	}
   349  	c := GroupMember{
   350  		ID:     "c",
   351  		Topics: []string{topic},
   352  	}
   353  
   354  	testCases := map[string]struct {
   355  		Data     []GroupMember
   356  		Expected []GroupMember
   357  	}{
   358  		"in order": {
   359  			Data:     []GroupMember{a, b},
   360  			Expected: []GroupMember{a, b},
   361  		},
   362  		"out of order": {
   363  			Data:     []GroupMember{a, c, b},
   364  			Expected: []GroupMember{a, b, c},
   365  		},
   366  	}
   367  
   368  	for label, test := range testCases {
   369  		t.Run(label, func(t *testing.T) {
   370  			membersByTopic := findMembersByTopic(test.Data)
   371  
   372  			if actual := membersByTopic[topic]; !reflect.DeepEqual(test.Expected, actual) {
   373  				t.Errorf("expected %v; got %v", test.Expected, actual)
   374  			}
   375  		})
   376  	}
   377  }
   378  
   379  func TestRackAffinityGroupBalancer(t *testing.T) {
   380  	t.Run("User Data", func(t *testing.T) {
   381  		t.Run("unknown zone", func(t *testing.T) {
   382  			b := RackAffinityGroupBalancer{}
   383  			zone, err := b.UserData()
   384  			if err != nil {
   385  				t.Fatal(err)
   386  			}
   387  			if string(zone) != "" {
   388  				t.Fatalf("expected empty zone but got %s", zone)
   389  			}
   390  		})
   391  
   392  		t.Run("configure zone", func(t *testing.T) {
   393  			b := RackAffinityGroupBalancer{Rack: "zone1"}
   394  			zone, err := b.UserData()
   395  			if err != nil {
   396  				t.Fatal(err)
   397  			}
   398  			if string(zone) != "zone1" {
   399  				t.Fatalf("expected zone1 az but got %s", zone)
   400  			}
   401  		})
   402  	})
   403  
   404  	t.Run("Balance", func(t *testing.T) {
   405  		b := RackAffinityGroupBalancer{}
   406  
   407  		brokers := map[string]Broker{
   408  			"z1": {ID: 1, Rack: "z1"},
   409  			"z2": {ID: 2, Rack: "z2"},
   410  			"z3": {ID: 2, Rack: "z3"},
   411  			"":   {},
   412  		}
   413  
   414  		tests := []struct {
   415  			name            string
   416  			memberCounts    map[string]int
   417  			partitionCounts map[string]int
   418  			result          map[string]map[string]int
   419  		}{
   420  			{
   421  				name: "unknown and known zones",
   422  				memberCounts: map[string]int{
   423  					"":   1,
   424  					"z1": 1,
   425  					"z2": 1,
   426  				},
   427  				partitionCounts: map[string]int{
   428  					"z1": 5,
   429  					"z2": 4,
   430  					"":   9,
   431  				},
   432  				result: map[string]map[string]int{
   433  					"z1": {"": 1, "z1": 5},
   434  					"z2": {"": 2, "z2": 4},
   435  					"":   {"": 6},
   436  				},
   437  			},
   438  			{
   439  				name: "all unknown",
   440  				memberCounts: map[string]int{
   441  					"": 5,
   442  				},
   443  				partitionCounts: map[string]int{
   444  					"": 103,
   445  				},
   446  				result: map[string]map[string]int{
   447  					"": {"": 103},
   448  				},
   449  			},
   450  			{
   451  				name: "remainder stays local",
   452  				memberCounts: map[string]int{
   453  					"z1": 3,
   454  					"z2": 3,
   455  					"z3": 3,
   456  				},
   457  				partitionCounts: map[string]int{
   458  					"z1": 20,
   459  					"z2": 19,
   460  					"z3": 20,
   461  				},
   462  				result: map[string]map[string]int{
   463  					"z1": {"z1": 20},
   464  					"z2": {"z2": 19},
   465  					"z3": {"z3": 20},
   466  				},
   467  			},
   468  			{
   469  				name: "imbalanced partitions",
   470  				memberCounts: map[string]int{
   471  					"z1": 1,
   472  					"z2": 1,
   473  					"z3": 1,
   474  				},
   475  				partitionCounts: map[string]int{
   476  					"z1": 7,
   477  					"z2": 0,
   478  					"z3": 7,
   479  				},
   480  				result: map[string]map[string]int{
   481  					"z1": {"z1": 5},
   482  					"z2": {"z1": 2, "z3": 2},
   483  					"z3": {"z3": 5},
   484  				},
   485  			},
   486  			{
   487  				name: "imbalanced members",
   488  				memberCounts: map[string]int{
   489  					"z1": 5,
   490  					"z2": 3,
   491  					"z3": 1,
   492  				},
   493  				partitionCounts: map[string]int{
   494  					"z1": 9,
   495  					"z2": 9,
   496  					"z3": 9,
   497  				},
   498  				result: map[string]map[string]int{
   499  					"z1": {"z1": 9, "z3": 6},
   500  					"z2": {"z2": 9},
   501  					"z3": {"z3": 3},
   502  				},
   503  			},
   504  			{
   505  				name: "no consumers in zone",
   506  				memberCounts: map[string]int{
   507  					"z2": 10,
   508  				},
   509  				partitionCounts: map[string]int{
   510  					"z1": 20,
   511  					"z3": 19,
   512  				},
   513  				result: map[string]map[string]int{
   514  					"z2": {"z1": 20, "z3": 19},
   515  				},
   516  			},
   517  		}
   518  
   519  		for _, tt := range tests {
   520  			t.Run(tt.name, func(t *testing.T) {
   521  
   522  				// create members per the distribution in the test case.
   523  				var members []GroupMember
   524  				for zone, count := range tt.memberCounts {
   525  					for i := 0; i < count; i++ {
   526  						members = append(members, GroupMember{
   527  							ID:       zone + ":" + strconv.Itoa(len(members)+1),
   528  							Topics:   []string{"test"},
   529  							UserData: []byte(zone),
   530  						})
   531  					}
   532  				}
   533  
   534  				// create partitions per the distribution in the test case.
   535  				var partitions []Partition
   536  				for zone, count := range tt.partitionCounts {
   537  					for i := 0; i < count; i++ {
   538  						partitions = append(partitions, Partition{
   539  							ID:     len(partitions),
   540  							Topic:  "test",
   541  							Leader: brokers[zone],
   542  						})
   543  					}
   544  				}
   545  
   546  				res := b.AssignGroups(members, partitions)
   547  
   548  				// verification #1...all members must be assigned and with the
   549  				// correct load.
   550  				minLoad := len(partitions) / len(members)
   551  				maxLoad := minLoad
   552  				if len(partitions)%len(members) != 0 {
   553  					maxLoad++
   554  				}
   555  				for _, member := range members {
   556  					assignments, _ := res[member.ID]["test"]
   557  					if len(assignments) < minLoad || len(assignments) > maxLoad {
   558  						t.Errorf("expected between %d and %d partitions for member %s", minLoad, maxLoad, member.ID)
   559  					}
   560  				}
   561  
   562  				// verification #2...all partitions are assigned, and the distribution
   563  				// per source zone matches.
   564  				partsPerZone := make(map[string]map[string]int)
   565  				uniqueParts := make(map[int]struct{})
   566  				for id, topicToPartitions := range res {
   567  
   568  					for topic, assignments := range topicToPartitions {
   569  						if topic != "test" {
   570  							t.Fatalf("wrong topic...expected test but got %s", topic)
   571  						}
   572  
   573  						var member GroupMember
   574  						for _, m := range members {
   575  							if id == m.ID {
   576  								member = m
   577  								break
   578  							}
   579  						}
   580  						if member.ID == "" {
   581  							t.Fatal("empty member ID returned")
   582  						}
   583  
   584  						var partition Partition
   585  						for _, id := range assignments {
   586  
   587  							uniqueParts[id] = struct{}{}
   588  
   589  							for _, p := range partitions {
   590  								if p.ID == int(id) {
   591  									partition = p
   592  									break
   593  								}
   594  							}
   595  							if partition.Topic == "" {
   596  								t.Fatal("empty topic ID returned")
   597  							}
   598  							counts, ok := partsPerZone[string(member.UserData)]
   599  							if !ok {
   600  								counts = make(map[string]int)
   601  								partsPerZone[string(member.UserData)] = counts
   602  							}
   603  							counts[partition.Leader.Rack]++
   604  						}
   605  					}
   606  				}
   607  
   608  				if len(partitions) != len(uniqueParts) {
   609  					t.Error("not all partitions were assigned")
   610  				}
   611  				if !reflect.DeepEqual(tt.result, partsPerZone) {
   612  					t.Errorf("wrong balanced zones.  expected %v but got %v", tt.result, partsPerZone)
   613  				}
   614  			})
   615  		}
   616  	})
   617  
   618  	t.Run("Multi Topic", func(t *testing.T) {
   619  		b := RackAffinityGroupBalancer{}
   620  
   621  		brokers := map[string]Broker{
   622  			"z1": {ID: 1, Rack: "z1"},
   623  			"z2": {ID: 2, Rack: "z2"},
   624  			"z3": {ID: 2, Rack: "z3"},
   625  			"":   {},
   626  		}
   627  
   628  		members := []GroupMember{
   629  			{
   630  				ID:       "z1",
   631  				Topics:   []string{"topic1", "topic2"},
   632  				UserData: []byte("z1"),
   633  			},
   634  			{
   635  				ID:       "z2",
   636  				Topics:   []string{"topic2", "topic3"},
   637  				UserData: []byte("z2"),
   638  			},
   639  			{
   640  				ID:       "z3",
   641  				Topics:   []string{"topic3", "topic1"},
   642  				UserData: []byte("z3"),
   643  			},
   644  		}
   645  
   646  		partitions := []Partition{
   647  			{
   648  				ID:     1,
   649  				Topic:  "topic1",
   650  				Leader: brokers["z1"],
   651  			},
   652  			{
   653  				ID:     2,
   654  				Topic:  "topic1",
   655  				Leader: brokers["z3"],
   656  			},
   657  			{
   658  				ID:     1,
   659  				Topic:  "topic2",
   660  				Leader: brokers["z1"],
   661  			},
   662  			{
   663  				ID:     2,
   664  				Topic:  "topic2",
   665  				Leader: brokers["z2"],
   666  			},
   667  			{
   668  				ID:     1,
   669  				Topic:  "topic3",
   670  				Leader: brokers["z3"],
   671  			},
   672  			{
   673  				ID:     2,
   674  				Topic:  "topic3",
   675  				Leader: brokers["z2"],
   676  			},
   677  		}
   678  
   679  		expected := GroupMemberAssignments{
   680  			"z1": {"topic1": []int{1}, "topic2": []int{1}},
   681  			"z2": {"topic2": []int{2}, "topic3": []int{2}},
   682  			"z3": {"topic3": []int{1}, "topic1": []int{2}},
   683  		}
   684  
   685  		res := b.AssignGroups(members, partitions)
   686  		if !reflect.DeepEqual(expected, res) {
   687  			t.Fatalf("incorrect group assignment.  expected %v but got %v", expected, res)
   688  		}
   689  	})
   690  }