├── .github └── logo.png ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── balance.go ├── balance_test.go ├── benchmark_test.go ├── example └── main.go └── go.mod /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr-karan/balance/d32c6ade6cf1476c2fe5e19c6a1021a27027c87d/.github/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Karan Sharma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | go test -v -failfast -race -coverpkg=./... -covermode=atomic 4 | 5 | .PHONY: bench 6 | bench: 7 | go test -v -failfast -bench=. -benchmem -run=^$$ 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | logo 3 |

4 | 5 | # balance 6 | 7 | A minimal Golang library for implementing weighted round-robin load balancing for a given set of items. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | go get github.com/mr-karan/balance 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | "fmt" 22 | "github.com/mr-karan/balance" 23 | 24 | // Create a new load balancer. 25 | b := balance.NewBalance() 26 | 27 | // Add items to the load balancer with their corresponding weights. 28 | b.Add("a", 5) 29 | b.Add("b", 3) 30 | b.Add("c", 2) 31 | 32 | // Get the next item from the load balancer. 33 | fmt.Println(b.Get()) 34 | 35 | // For 10 requests, the output sequence will be: [a b c a a b a c b a] 36 | ) 37 | ``` 38 | 39 | ## Algorithm 40 | 41 | The algorithm is based on the [Smooth Weighted Round Robin](https://github.com/phusion/nginx/commit/27e94984486058d73157038f7950a0a36ecc6e35) used by NGINX. 42 | 43 | > Algorithm is as follows: on each peer selection we increase current_weight 44 | of each eligible peer by its weight, select peer with greatest current_weight 45 | and reduce its current_weight by total number of weight points distributed 46 | among peers. 47 | 48 | ## Examples 49 | 50 | ### Round Robin 51 | 52 | For implementing an equal weighted round-robin load balancer for a set of servers, you can use the following config: 53 | 54 | ```go 55 | b.Add("server1", 1) 56 | b.Add("server2", 1) 57 | b.Add("server3", 1) 58 | ``` 59 | 60 | Since the weights of all 3 servers are equal, the load balancer will distribute the load equally among all 3 servers. 61 | 62 | ### Weighted Round Robin 63 | 64 | For implementing a weighted round-robin load balancer for a set of servers, you can use the following config: 65 | 66 | ```go 67 | b.Add("server1", 5) 68 | b.Add("server2", 3) 69 | b.Add("server3", 2) 70 | ``` 71 | 72 | The load balancer will distribute the load in the ratio of 5:3:2 among the 3 servers. 73 | 74 | ## Benchmark 75 | 76 | ```bash 77 | go test -v -failfast -bench=. -benchmem -run=^$ 78 | goos: linux 79 | goarch: amd64 80 | pkg: github.com/mr-karan/balance 81 | cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz 82 | BenchmarkBalance 83 | BenchmarkBalance/items-10 84 | BenchmarkBalance/items-10-8 18249529 63.82 ns/op 0 B/op 0 allocs/op 85 | BenchmarkBalance/items-100 86 | BenchmarkBalance/items-100-8 9840943 119.5 ns/op 0 B/op 0 allocs/op 87 | BenchmarkBalance/items-1000 88 | BenchmarkBalance/items-1000-8 1608460 767.1 ns/op 0 B/op 0 allocs/op 89 | BenchmarkBalance/items-10000 90 | BenchmarkBalance/items-10000-8 123394 9621 ns/op 0 B/op 0 allocs/op 91 | BenchmarkBalance/items-100000 92 | BenchmarkBalance/items-100000-8 10000 102295 ns/op 0 B/op 0 allocs/op 93 | PASS 94 | ok github.com/mr-karan/balance 7.927s 95 | ``` 96 | -------------------------------------------------------------------------------- /balance.go: -------------------------------------------------------------------------------- 1 | package balance 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | // ErrDuplicateID error is thrown when attempt to add an ID 10 | // which is already added to the balancer. 11 | ErrDuplicateID = errors.New("entry already added") 12 | // ErrIDNotFound is thrown when removing a non-existent ID. 13 | ErrIDNotFound = errors.New("id not found") 14 | ) 15 | 16 | // Balance represents a smooth weighted round-robin load balancer. 17 | type Balance struct { 18 | sync.RWMutex 19 | 20 | // items is the list of items to balance 21 | items []*Item 22 | // next is the index of the next item to use. 23 | next *Item 24 | } 25 | 26 | // NewBalance creates a new load balancer. 27 | func NewBalance() *Balance { 28 | return &Balance{ 29 | items: make([]*Item, 0), 30 | } 31 | } 32 | 33 | // Item represents the item in the list. 34 | type Item struct { 35 | // id is the id of the item. 36 | id string 37 | // weight is the weight of the item that is given by the user. 38 | weight int 39 | // current is the current weight of the item. 40 | current int 41 | } 42 | 43 | func NewItem(id string, weight int) *Item { 44 | return &Item{ 45 | id: id, 46 | weight: weight, 47 | current: 0, 48 | } 49 | } 50 | 51 | func (b *Balance) Add(id string, weight int) error { 52 | b.Lock() 53 | defer b.Unlock() 54 | for _, v := range b.items { 55 | if v.id == id { 56 | return ErrDuplicateID 57 | } 58 | } 59 | 60 | b.items = append(b.items, NewItem(id, weight)) 61 | 62 | return nil 63 | } 64 | 65 | func (b *Balance) Get() string { 66 | b.Lock() 67 | defer b.Unlock() 68 | 69 | if len(b.items) == 0 { 70 | return "" 71 | } 72 | 73 | // Total weight of all items. 74 | var total int 75 | 76 | // Loop through the list of items and add the item's weight to the current weight. 77 | // Also increment the total weight counter. 78 | var max *Item 79 | for _, item := range b.items { 80 | item.current += item.weight 81 | total += item.weight 82 | 83 | // Select the item with max weight. 84 | if max == nil || item.current > max.current { 85 | max = item 86 | } 87 | } 88 | 89 | // Select the item with the max weight. 90 | b.next = max 91 | // Reduce the current weight of the selected item by the total weight. 92 | max.current -= total 93 | 94 | return max.id 95 | } 96 | 97 | // Remove deletes an item by ID from the balancer. 98 | func (b *Balance) Remove(id string) error { 99 | b.Lock() 100 | defer b.Unlock() 101 | 102 | for i, item := range b.items { 103 | if item.id == id { 104 | b.items = append(b.items[:i], b.items[i+1:]...) 105 | return nil 106 | } 107 | } 108 | 109 | return ErrIDNotFound 110 | } 111 | 112 | // ItemIDs returns a list of all item IDs in the balancer. 113 | func (b *Balance) ItemIDs() []string { 114 | b.RLock() 115 | defer b.RUnlock() 116 | 117 | ids := make([]string, len(b.items)) 118 | for i, item := range b.items { 119 | ids[i] = item.id 120 | } 121 | return ids 122 | } 123 | -------------------------------------------------------------------------------- /balance_test.go: -------------------------------------------------------------------------------- 1 | package balance_test 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "sync/atomic" 7 | "testing" 8 | 9 | "github.com/mr-karan/balance" 10 | ) 11 | 12 | func TestBalance(t *testing.T) { 13 | // Test Init. 14 | t.Run("init", func(t *testing.T) { 15 | bl := balance.NewBalance() 16 | if bl.Get() != "" { 17 | t.Error("Expected empty string") 18 | } 19 | }) 20 | 21 | // Test round robin. 22 | t.Run("round robin", func(t *testing.T) { 23 | bl := balance.NewBalance() 24 | bl.Add("a", 1) 25 | bl.Add("b", 1) 26 | bl.Add("c", 1) 27 | result := make(map[string]int) 28 | for i := 0; i < 999; i++ { 29 | result[bl.Get()]++ 30 | } 31 | 32 | if result["a"] != 333 || result["b"] != 333 || result["c"] != 333 { 33 | t.Error("Wrong counts", result) 34 | } 35 | }) 36 | 37 | t.Run("adding duplicate entry", func(t *testing.T) { 38 | bl := balance.NewBalance() 39 | err := bl.Add("c", 1) 40 | if !errors.Is(err, nil) { 41 | t.Error("Wrong error received", err.Error()) 42 | } 43 | 44 | err = bl.Add("c", 1) 45 | if !errors.Is(err, balance.ErrDuplicateID) { 46 | t.Error("Wrong error received", err.Error()) 47 | } 48 | }) 49 | 50 | // Test weighted. 51 | t.Run("weighted custom split", func(t *testing.T) { 52 | bl := balance.NewBalance() 53 | bl.Add("a", 2) 54 | bl.Add("b", 1) 55 | bl.Add("c", 1) 56 | result := make(map[string]int) 57 | for i := 0; i < 1000; i++ { 58 | result[bl.Get()]++ 59 | } 60 | 61 | if result["a"] != 500 || result["b"] != 250 || result["c"] != 250 { 62 | t.Error("Wrong counts", result) 63 | } 64 | }) 65 | 66 | t.Run("weighted another custom split", func(t *testing.T) { 67 | bl := balance.NewBalance() 68 | bl.Add("a", 5) 69 | bl.Add("b", 3) 70 | bl.Add("c", 2) 71 | result := make(map[string]int) 72 | for i := 0; i < 1000; i++ { 73 | result[bl.Get()]++ 74 | } 75 | 76 | if result["a"] != 500 || result["b"] != 300 || result["c"] != 200 { 77 | t.Error("Wrong counts", result) 78 | } 79 | }) 80 | 81 | // Test with one item as zero weight. 82 | t.Run("weighted with zero", func(t *testing.T) { 83 | bl := balance.NewBalance() 84 | bl.Add("a", 0) 85 | bl.Add("b", 1) 86 | bl.Add("c", 1) 87 | result := make(map[string]int) 88 | for i := 0; i < 1000; i++ { 89 | result[bl.Get()]++ 90 | } 91 | 92 | if result["a"] != 0 || result["b"] != 500 || result["c"] != 500 { 93 | t.Error("Wrong counts", result) 94 | } 95 | }) 96 | 97 | // Test remove item. 98 | t.Run("remove item", func(t *testing.T) { 99 | bl := balance.NewBalance() 100 | bl.Add("a", 1) 101 | bl.Add("b", 1) 102 | bl.Add("c", 1) 103 | 104 | err := bl.Remove("b") 105 | if err != nil { 106 | t.Error("Expected no error, got", err) 107 | } 108 | 109 | ids := bl.ItemIDs() 110 | expected := map[string]bool{"a": true, "c": true} 111 | for _, id := range ids { 112 | if !expected[id] { 113 | t.Error("Unexpected ID in list", id) 114 | } 115 | } 116 | 117 | // Ensure removed item isn't returned by a Get. 118 | for i := 0; i < 100; i++ { 119 | if bl.Get() == "b" { 120 | t.Error("Removed item 'b' still returned by Get") 121 | } 122 | } 123 | }) 124 | 125 | // Test remove non-existent item. 126 | t.Run("remove non-existent item", func(t *testing.T) { 127 | bl := balance.NewBalance() 128 | bl.Add("a", 1) 129 | err := bl.Remove("x") 130 | if !errors.Is(err, balance.ErrIDNotFound) { 131 | t.Error("Expected ErrIDNotFound, got", err) 132 | } 133 | }) 134 | 135 | // Test list items ids. 136 | t.Run("list items", func(t *testing.T) { 137 | bl := balance.NewBalance() 138 | bl.Add("x", 3) 139 | bl.Add("y", 2) 140 | 141 | ids := bl.ItemIDs() 142 | expected := map[string]bool{"x": true, "y": true} 143 | for _, id := range ids { 144 | if !expected[id] { 145 | t.Error("Unexpected ID in list", id) 146 | } 147 | } 148 | 149 | if len(ids) != 2 { 150 | t.Error("Expected 2 items, got", len(ids)) 151 | } 152 | }) 153 | } 154 | 155 | func TestBalance_Concurrent(t *testing.T) { 156 | t.Run("concurrent", func(t *testing.T) { 157 | var ( 158 | a, b, c int64 159 | ) 160 | bl := balance.NewBalance() 161 | bl.Add("a", 1) 162 | bl.Add("b", 1) 163 | bl.Add("c", 1) 164 | 165 | var wg sync.WaitGroup 166 | 167 | for i := 0; i < 999; i++ { 168 | wg.Add(1) 169 | go func() { 170 | defer wg.Done() 171 | switch bl.Get() { 172 | case "a": 173 | atomic.AddInt64(&a, 1) 174 | case "b": 175 | atomic.AddInt64(&b, 1) 176 | case "c": 177 | atomic.AddInt64(&c, 1) 178 | default: 179 | t.Error("Wrong item") 180 | } 181 | }() 182 | } 183 | 184 | wg.Wait() 185 | 186 | if a != 333 || b != 333 || c != 333 { 187 | t.Error("Wrong counts", a, b, c) 188 | } 189 | }) 190 | } 191 | -------------------------------------------------------------------------------- /benchmark_test.go: -------------------------------------------------------------------------------- 1 | package balance_test 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | "testing" 7 | "time" 8 | 9 | "github.com/mr-karan/balance" 10 | ) 11 | 12 | func BenchmarkBalance(b *testing.B) { 13 | b.ReportAllocs() 14 | rand.Seed(time.Now().UnixNano()) 15 | 16 | for n := 10; n <= 100000; n *= 10 { 17 | b.Run("items-"+strconv.Itoa(n), func(b *testing.B) { 18 | bl := balance.NewBalance() 19 | items := generateItems(n) 20 | for i, w := range items { 21 | bl.Add(i, w) 22 | } 23 | 24 | b.ResetTimer() 25 | b.RunParallel(func(p *testing.PB) { 26 | for p.Next() { 27 | _ = bl.Get() 28 | } 29 | }) 30 | }) 31 | } 32 | 33 | } 34 | 35 | func generateItems(n int) map[string]int { 36 | items := make(map[string]int) 37 | for i := 0; i < n; i++ { 38 | items["server-"+strconv.Itoa(i)] = rand.Intn(100) + 50 39 | } 40 | return items 41 | } 42 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | balance "github.com/mr-karan/balance" 7 | ) 8 | 9 | func main() { 10 | // Create a new load balancer. 11 | b := balance.NewBalance() 12 | 13 | // Add items to the load balancer. 14 | b.Add("a", 5) 15 | b.Add("b", 3) 16 | b.Add("c", 2) 17 | 18 | for i := 0; i < 10; i++ { 19 | item := b.Get() 20 | fmt.Printf("%s ", item) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mr-karan/balance 2 | 3 | go 1.19 4 | --------------------------------------------------------------------------------